uncgits / emma-api-php-library
PHP Library used to interact with the Emma API for UNCG
Requires
- php: >=7.0.0
- nesbot/carbon: >=1.22.0
Suggests
- guzzlehttp/guzzle: 6.* recommended to make HTTP request with Guzzle adapter
README
This package is a PHP library used to interact with the Emma REST API.
Scope
This package is not (yet) as a comprehensive interface with the Emma API. If you would like to contribute additional functionality to this library, pull requests are very welcome.
Installation and Quick Start
To install the package: composer require uncgits/emma-api-php-library
If you are using the built-in Guzzle driver, you will also need to make sure you have Guzzle 6. in your project: `composer require guzzlehttp/guzzle:"6."`. This package does not list Guzzle as a dependency because it is not strictly required (adapters for other HTTP clients are in the plans, and you can always write your own).
Use it!
Create a config object
This library is set up to be flexible. By default the intent is for you to create a Config object per Emma Account you are interacting with. To do so, create a class in your application that extends Uncgits\EmmaApi\EmmaApiConfig
. Then, on __construct()
, utilize $this->setPublicKey()
and $this->setPrivateKey()
(and optionally $this->setAccountId()
) methods to set up your API credentials. Example:
<?php
namespace App\EmmaConfigs;
use Uncgits\EmmaApi\EmmaApiConfig;
class TestAccount extends EmmaApiConfig
{
public function __construct()
{
$this->setPublicKey('super-secret-key-that-you-would-reference-from-a-non-committed-file-of-course'); // please don't commit your keys...
$this->setPrivateKey('super-secret-key-that-you-would-reference-from-a-non-committed-file-of-course'); // please don't commit your keys...
$this->setAccountId('123456789'); // you could also leave this out and set it using `->account()` during your API calls
}
}
Instantiate the EmmaApi
class
Choose the Client you want (found in
Uncgits\EmmaApi\Clients
), based on the type of call(s) you want to make.Then, choose the Adapter you want to use for the HTTP interaction (found in
Uncgits\EmmaApi\Adapters
).
By default, the
Guzzle
adapter is available with this package (You can write your own; more later).
Finally, choose the Config you want to use.
Instantiate the
Uncgits\EmmaApi\EmmaApi
class, and pass in the above three objects either using a constructor array, or the setter methods:
// OPTION 1 - array in constructor
// instantiate a client
$membersClient = new \Uncgits\EmmaApi\Clients\Members;
// instantiate an adapter
$adapter = new \Uncgits\EmmaApi\Adapters\Guzzle;
// instantiate a config
$config = new App\EmmaConfigs\TestAccount;
// pass as array to API class
$api = new \Uncgits\EmmaApi\EmmaApi([
'client' => $membersClient,
'adapter' => $adapter,
'config' => $config,
]);
// OPTION 2 - use setters
// instantiate a client
$membersClient = new \Uncgits\EmmaApi\Clients\Members;
// instantiate an adapter
$adapter = new \Uncgits\EmmaApi\Adapters\Guzzle;
// instantiate a config
$config = new App\EmmaConfigs\TestAccount;
// instantiate API class
$api = new \Uncgits\EmmaApi\EmmaApi;
$api->setClient($membersClient);
$api->setAdapter($adapter);
$api->setConfig($config);
Or, take the shortcut and instead pass the class names using either of the methods above:
// OPTION 1 - pass class names
use \Uncgits\EmmaApi\Clients\Members;
use \Uncgits\EmmaApi\Adapters\Guzzle;
use App\EmmaConfigs\TestAccount;
// instantiate the API class and pass in the array using class names
$api = new \Uncgits\EmmaApi\EmmaApi([
'client' => Members::class,
'adapter' => Guzzle::class,
'config' => TestAccount::class,
]);
// OPTION 2 - use setters
use \Uncgits\EmmaApi\Clients\Members;
use \Uncgits\EmmaApi\Adapters\Guzzle;
use App\EmmaConfigs\TestAccount;
$api = new \Uncgits\EmmaApi\EmmaApi([
'client' => Members::class,
'adapter' => Guzzle::class,
'config' => TestAccount::class,
]);
Then, once you have the client, if you want to list all Accounts:
// make the call
$result = $membersClient->listMembers(); // methods are named simply based on the operation being performed
var_dump($result->getContent()); // you receive a EmmaApiResult object
Client fluent shortcut
You may want to use a different Client while maintaining the same Adapter and Config. You can do this permanently by using the setClient()
on the API class, or you can do it for a single transaction using the fluent using()
chainable method.
using()
will accept a complete class name (with namespace) for a Client class, or a simplified class name for a built-in Client class, like 'groups' or 'signupforms'.
use Uncgits\EmmaApi\Clients\Groups;
use Uncgits\EmmaApi\Clients\Members;
use Uncgits\EmmaApi\Adapters\Guzzle;
use App\EmmaConfigs\TestAccount;
$api = new \Uncgits\EmmaApi\EmmaApi([
'client' => Members::class,
'adapter' => Guzzle::class,
'config' => TestAccount::class,
]);
// API class will use Accounts client.
$api->listMembers();
// use explicit setter - now API class will use Groups client for all future calls
$api->setClient(Groups::class);
$api->listGroups();
$api->getGroup(123); // still Groups client
// OR, use fluent setter with explicit class
$api->using(Groups::class)->listGroups();
$api->listMembers(); // back to original Accounts Client
// OR, fluent setter with implied class (from default library)
$api->using('groups')->listGroups();
$api->listMembers(); // back to Accounts Client
Account fluent shortcut
Similarly to the Client shortcut above, you can set the account "on the fly" with the account()
method. So instead of doing this:
$api->getConfig()->setAccountId('12345');
$api->using('groups')->listGroups()
You can do this inline as you make the call:
$api->account('12345')->using('groups')->listGroups();
This is in place because of the structure within Emma; the Account level is an important part of every API call, but there is often a need to perform actions across multiple Accounts.
Setting parameters
Some API calls need additional parameters, and there are two different ways to pass them. The way(s) you choose are directly in line with the following logic:
If the parameter belongs in the URL, it will be required as an argument to the method call. Example:
getGroup()
callsapi/v1/#account_id/groups/#member_group_id
.#account_id
is set as part of the Config file, but#member_group_id
is the ID of the group you are requesting. Therefore, the signature for the method isgetGroup($member_group_id)
, and you would pass in your ID value there.If the parameter is not contained in the URL, you need to set it as a body parameter. This includes all required parameters as well as optional parameters. Example:
listGroups()
does not require any parameters beyond the built-in#account_id
, but you may optionally provide agroup_types
parameter. Use the chainableaddParameters()
method on the client object like so:
$parameters = [
'group_types' => 'h',
];
$result = $groupsClient->addParameters($paramegers)->listGroups();
Refer to the API documentation for the accepted parameters, as well as the structure of the parameters (especially for multi-level nested parameters). This library attempts to validate parameters to a point, but as of initial release cannot validate some parameters that are deeply-nested.
Downloading files
Some calls (such as the downloadExport()
method on the Exports client) will provide a streamed file resource. Many HTTP libraries (including Guzzle) provide convenient ways to retrieve these files, and so this library offers a simple hook into that functionality.
You can use the setDownloadPath()
method on the Config class to set a path for the downloaded file. Once that value is set, the HTTP Adapter class will be responsible for checking it and implementing the download to that location. In the bundled Guzzle wrapper, this simply involves setting one request option (sink
).
$config->setDownloadPath('my/file.csv');
$api->using('exports')->downloadExport('1234567');
There is also a fluent setter on the EmmaApi
class you can use instead - downloadTo()
:
$api->using('exports')->downloadTo('my/file.csv')->downloadExport('1234567');
Laravel wrapper
This package has a dedicated wrapper package for Laravel, that includes container bindings, facades, config files, adapters, and other utilities to help you use this package easily within a Laravel application.
Detailed Usage
Architecture
General Architecture
This library is comprised of many Clients, each of which interacts with one specific category / area of the Emma API. For instance, there is a Quizzes client, an Accounts client, a Users client, and so on. Each client class is based fully on its architecture in the Emma API Documentation. All client classes implement the Uncgits\EmmaApi\Clients\EmmaApiClientInterface
interface, which is currently an empty interface but helps to identify each Client as a "valid" Client (so you can build your own). The job of a Client is to return an array that can be parsed into a EmmaApiEndpoint
object, which requires an endpoint string, HTTP method string, and optional array of required parameters for the call to that Emma API method.
Adapter classes are basically abstracted HTTP request handlers. They worry about structuring and making API calls to Emma, handling pagination as necessary, and formatting the response in a simple, structured way (using a EmmaApiResult
object). These Adapter classes implement the Uncgits\EmmaApi\Adapters\EmmaApiAdapterInterface
interface. At the time of initial release, only an adapter for Guzzle is included - however, more will be added later, and you can always write your own to use your PHP HTTP library of choice. (Or straight cURL. Nobody's judging.) Adapters technically exist as properties on the Client class.
Config classes are classes that configure basic things that the adapter needs to know about in order to interact with the Emma API. No concrete classes are included in this package - only the abstract EmmaApiConfig
class. The purpose for this architecture is so that you can create several classes to support different Emma environments or accounts.
The Master API class is essentially the "traffic controller" class, that worries about making sure the separate components (Client, Adapter, Config) know everything they need to know in order to execute the API call. This class exists as such so that it can be extended as needed. Basically, when asked to make a call, the API class should know everything about what is required to make said API call, including the Client, Adapter, and Config that are being used, the endpoint and method, and so on, making it easy to leverage this class for informational purposes (such as logging or caching... see the Laravel wrapper package for an example).
Naming
Each client's methods are named as fluently as possible based on the name and operation of the method according to API documentation. General terms used in naming attempt to mirror RESTful operations, such as list
for retrieving multiple records, get
for retrieving single records, and so on. For example, the Groups client contains methods like listGroups()
, createGroups()
, and so on.
Aliasing
Where prudent, additional methods are defined on the client classes to help with additional flexibility. For instance, Emma's API documentation contains a POST
method for creating "one or more new member groups". The main method is createGroups()
, which expects an array of records, but you can also use the createGroup()
(singular), which will wrap up what it is passed and ultimately call createGroups()
.
Pagination
Pagination is handled automatically, where and when necessary, at the adapter level. For each "list" API call, an initial call will be made to the endpoint including the count=true
parameter, which will then fetch an initial count to inform pagination. Then, subsequent calls will be made, employing the start
and end
parameters until the initial record count is met. This is all in accordance with the documentation.
The default number of records to retrieve per page is set at the preconfigured default of 500. When a paginated endpoint is called, the code will use the per-page, max available results, and the max requested results (see below) to determine how many API calls are necessary to complete the request. It should only be necessary to modify this if you want either a different starting point in the result set, or a limited number of total results.
Because of the way pagination is handled, each client transaction may initiate several API calls before it reaches completion. See below ("Handling Results") for details on how results are presented.
Customizing Pagination or Result Set
To customize the start point of your call (e.g. starting in the middle of a result set), use the fluent setStart()
method:
// skip the first 100 members
$result = $api->setStart(100)->listMembers();
To pull only a specified number of max results from the call, use the fluent setMaxResults()
method:
// start at the top but return only 250 members
$result = $api->setMaxResults(250)->listMembers();
You can, of course, combine them:
// skip the first 100 members AND return only 250 members
$result = $api->setStart(100)->setMaxResults(250)->listMembers();
Finally, there should be no need to do so, but if you want to change the pagination away from the default value of 500, use the Config class's setPerPage()
method, available off of the main API class:
// paginate in groups of 100 instead of default 500.
$result = $api->setPerPage(100)->listMembers();
Generally speaking, it is advisable to keep your pagination at the prescribed default of 500, as Emma's API rate limits are on the stricter side... thus you generally want to get more data on each transaction.
Handling Results
Every time a client transaction is requested, this library will encapsulate important information and present it in the Uncgits\EmmaApi\EmmaApiResult
class. Within that class, you are able to access structured information on each API call made during that transaction, as well as an aggregated resultset that should be iterable as an array. For example, if you ask for all Accounts, and it requires 5 API calls of page size 10 to retrieve them, your result contents would be a single array of all accounts; this is designed to save you time in parsing them yourself!
$result = $api->listMembers();
// get the result content - your Member objects
$accounts = $result->getContent();
// get a list of the API calls made
$apiCalls = $result->getCalls();
// get the first API call made
$firstCall = $result->getFirstCall();
// get the last API call made
$lastCall = $result->getLastCall();
// get the aggregate status code (success or failure) based on the result of all calls
$status = $result->getStatus();
// get the aggregate message (helpful if there were failures) based on the result of all calls
$status = $result->getMessage();
The API call array
Each API call (retrieved from the $calls
array on the EmmaApiResult
object) is made up of some key information that may be useful as you deal with things like throttling (see below), or other meta information on your calls. The API call array stores information on both the request (what is sent) and the response (what is received), and is structured as follows:
[
'request' => [
'endpoint' => $endpoint, // the final assembled endpoint
'method' => $method, // get, post, put, delete
'headers' => $requestOptions['headers'], // all headers passed - includes bearer info
'proxy' => $this->config->getProxy(), // proxy host and port, if using
'parameters' => $this->parameters, // any parameters used by the client
],
'response' => [
'headers' => $response->getHeaders(), // raw headers
'pagination' => $this->parse_http_rels($response->getHeaders()), // parsed pagination information
'code' => $response->getStatusCode(), // 200, 403, etc.
'reason' => $response->getReasonPhrase(), // OK, Forbidden, etc.
'rate-limit-remaining' => $response->getHeader('X-Rate-Limit-Remaining') ?? '', // convenience item for rate limit bucket level remaining
'body' => json_decode($response->getBody()->getContents()) // raw body content of the response
],
]
Rate limit / throttling
This library does not account for Rate Limit, Throttling, or automatic retries / exponential backoff. Most of the time, those issues are only encountered when running scripts that would invoke simultaneous API calls, and will not be a problem even when paginating through a large dataset. From Emma API documentation:
To prevent accidental overuse, calls to Emma's API are limited to 180 calls per minute. If you exceed the limit, you‘ll receive a response of 403 Rate Limit Exceeded until enough time has elapsed between calls.
This library is meant to act as an interface to the Emma REST API; therefore it does not need to be concerned about how it is being used in client applications. You should take care to handle this in your application code - if rate limits are hit, you can expect this library to tell you by way of the response information on the individual API calls (code 403 with "Rate Limit Exceeded" message).
As a convenience, rate limit information is provided in the base level array of each API call result (see above), so that you do not need to parse through headers yourself.
Using a proxy
If you need to use an HTTP proxy, set that up in your EmmaApiConfig
object using setProxyHost()
, setProxyPort()
, and useProxy()
.
Passing additional headers
If you need to set additional headers on your requests, you can utilize the setAdditionalHeaders()
method on the client class, which accepts an array of key-value pairs.
Writing your own Adapters
An adapter is responsible for everything involved in the actual interaction with the Emma API. The Adapter's responsibilities include:
- assembling the call endpoint, headers, parameters, body, and other options
- making the proper HTTP request
- parsing the response
- reading pagination headers and determining whether another call is necessary to fulfill the requested transaction
- reporting errors
- collating results into a single array
- recording all calls made in a transaction.
The EmmaApiAdapterInterface
interface shows how an adapter should be implemented. Most of the basic methods in that interface (setters, getters, convenience aliases, etc.) are implemented for you in the ExecutesApiCalls
trait, so generally speaking you should use that trait as a good first step. (Of course you can always override methods on the Trait if you prefer.)
On each adapter, therefore, that leaves you to implement the following methods on your own:
call()
transaction()
parsePagination()
normalizeResult()
Writing your own Clients
All Clients are implementations of the EmmaApiClientInterface
class, and passed in explicitly to the API object - and therefore writing your own Client classes is possible. There is no concrete implementation required by this interface class at this time...you can begin writing your custom Client anytime!.
Theoretically this API library will be complete at some point, and so writing custom Clients won't be necessary, but as this library is still growing it is possible that some API functionality is missing, and you'll want to fill gaps yourself. If you do, please consider creating a pull request to add your adapter to this repo!
Extending the API class
If you need additional functionality like logging or caching, you can write your own EmmaApi
wrapper class that extends this EmmaApi
class. The most common use cases would be to set some default values for the Config and Adapter classes, perhaps in the constructor, or to override the execute()
method to perform additional operations before the transaction is kicked off or after it is finished.
Contributing
Please feel free to submit a pull request to this package to help improve its coverage of the Canvas API, and/or to add Adapters or fix issues. The package was initially built to serve the needs of UNC Greensboro's immediate needs around Canvas integration, ad hoc, and is therefore not yet comprehensive.
License
See the LICENSE file for license rights and limitations (BSD).
Questions? Concerns?
Please use the Issue Tracker on this repository for reporting bugs. For security-related concerns, please contact its-laravel-devs-l@uncg.edu directly rather than using the Issue Tracker.
Version History
1.1.1
- Hotfix for return value of non-paginated transactions
1.1
- Pagination revamp
1.0.1
- BSD license inclusion
1.0
- Official release
0.4.2
- fixed errant client name for
Subscriptions
0.4.1
- change to PSR-4 declaration since we were already following it
0.4
- Corrections for some
Response
methods incorrectly named as "list" methods (with pagination)
0.3
- Adds download support
0.2
- Fix for when the
count
request returns 0 results on a paginated call (extra call is no longer made) - Add
Groups.listMembers()
alias - add
account()
fluent setter - add check to ensure that we have an account ID set before making a call
- remove empty check on call body (since the
count
request can be 0, which evals to empty) - clarifications around using accounts in the library (e.g. have a default account if you want, or set it on the Config class as you go)
0.1
- Ported over from Canvas API PHP Library (apologies for lingering references to Canvas / Instructure / LMS terminology)
- First tagged release
- Basic implementation of the following operation sets (not all fully tested):
- Automation
- Exports
- Fields
- Groups
- Mailings
- Members
- Response
- Searches
- Signup Forms
- Subscriptions
- Webhooks