kix/apiranha

dev-master 2018-06-22 12:28 UTC

README

Apiranha is a library that makes consuming APIs easier and faster. Some of the inspiration for Apiranha comes from Retrofit

Travis Ci Code Climate Coverage Status

Example usage

A complete example can be found at examples/. See ExampleCommand and BuilderExampleCommand.

Quick start

Make sure you have Composer installed, then run this in your project folder:

composer require kix/apiranha='dev-master'

Also, you'll need to require guzzlehttp/guzzle if you don't want to bring your own implementation of an HTTP client.

In order to consume Github API, for example, first you need to declare your endpoints in an interface and annotate those. The annotations available under the Kix\Apiranha\Annotation namespace are:

  • HTTP/REST method annotations:
    • Get
    • CGet
    • Delete
    • Post
    • Put
  • Returns, a special annotation to denote the endpoint return type.

First, you need to decide which data you actually need from the endpoint. In our case, we're just getting the ID, name, language and stargazers count from the Github REST API:

namespace \Kix\Apiranha\Examples\Model;

class Repository
{
    private $id;
    private $name;
    private $language;
    private $stargazersCount;
}

Of course, it will be necessary for you to read the properties, so you could either declare the fields as public, or you could add getters for the fields.

Next, to enable getting a single repo from the Github API, you would declare an interface like this (note the annotations):

use Kix\Apiranha\Annotation as Rest;

interface GithubApi
{
    /**
     * @Rest\Returns("\Kix\Apiranha\Examples\Model\Repository")
     * @Rest\Get("/repos/{username}/{repo}")
     */
    public function getRepo(string $username, string $repo);
}

Here, we say that the getRepo method will be returning data from the /repos endpoint. Note that the URL parameters are consistently named and also typehinted with string: this will help us generate a correct URL for a specific call.

After you've implemented your endpoint interface and your model, it's time for the magic to happen:

use Kix\Apiranha\Builder;
use Kix\Apiranha\Examples\Definition\GithubApi;

/** @var $endpoint GithubApi */
$endpoint = Builder::createEndpoint('http://api.github.com', [GithubApi::class]);

Calling Builder::createEndpoint, you get back an object that represents your API in terms of the interface you have declared previously. Which, in turn, means you can annotate it with @var $endpoint GithubApi.

Since now, all the methods you have declared in the interface become available via the endpoint object. Here's how it looks:

 > $endpoint->listRepos('kix');

=> array(30) {
=>   object(Kix\Apiranha\Examples\Model\Repository)#76 (4) {
=>     ["id":"Kix\Apiranha\Examples\Model\Repository":private]=>
=>     int(43456580)
=>     ["name":"Kix\Apiranha\Examples\Model\Repository":private]=>
=>     string(6) "apiranha"
=>     ["language":"Kix\Apiranha\Examples\Model\Repository":private]=>
=>     NULL
=>     ["stargazersCount":"Kix\Apiranha\Examples\Model\Repository":private]=>
=>     int(1)
=>   }
=>   ...
=> }

Digging deeper

Well, what happens under the hood? The Builder you've used in this simple API client is just a facade that helps you do stuff quick, and hides a lot of implementation details you might not actually care about. However, there's more to it than just calling a method and getting a result back. Here's what the builder presumes:

  • Your routes will always be declared in the annotations you provide, and the parameters will be bound to the URIs as-is;
  • You will be using Guzzle as the HTTP client (which this package will suggest upon installation)
  • The API serialization format will be JSON, handled by Symfony's serializer
  • Model instances your API will return will be hydrated using PHP's reflection API (which could be costly)

If some of these statements do not comply with your requirements, you're always free to extend the logic behind Apiranha.

Builder under the hood

Let's take a look at Builder's insides. Here's what the createEndpoint facade looks like:

    public static function createEndpoint($baseUrl, array $definitions, array $listeners = array(), HttpAdapterInterface $adapter = null, Router $router = null)
    {
        if (!$adapter) {
            $adapter = new GuzzleHttpAdapter(new Client());
        }

        if (!$router) {
            $router = new Router();
        }

        if (!count($listeners)) {
            $serializerAdapter = new SymfonySerializerAdapter(
                new Serializer([], [new JsonEncoder()])
            );
            $serializerAdapter->addContentType('application/json', 'json');

            $listeners[Endpoint::LISTENER_AFTER_RESPONSE] = new ContentTypeListenener($serializerAdapter);
            $listeners[Endpoint::LISTENER_AFTER_DATA] = new ReflectionHydratorListener();
        }

        $endpoint = new Endpoint($adapter, $router, $baseUrl);

        foreach ($listeners as $evt => $listener) {
            $endpoint->addListener($evt, $listener);
        }

        $driver = new AnnotationDriver();

        foreach ($definitions as $interfaceName) {
            $resources = $driver->createDefinitions($interfaceName);
            foreach ($resources as $resource) {
                $endpoint->addResourceDefinition($resource);
            }
        }

        return $endpoint;
    }

Note that you still can pass an array of listeners, an HTTP adapter and a router as arguments to this factory method. However, if you want to do things manually, or you dislike when things are decided for you (or, you just hate static facades), you could always reimplement this logic manually. That's all there is, basically.

Now, to the specifics. Why do we need all these things the Builder instantiates for us?

HTTP layer

First of all, in order to consume an API, you need to somehow interact with the 3rd party server. For this, you need an HTTP adapter. It should implement Kix\Apiranha\HttpAdapter\HttpAdapterInterface, and GuzzleHttpAdapter is an example you can use right now.

The only method you have to implement is send(RequestInterface $request): ResponseInterface, where RequestInterface and ResponseInterface are standard Psr\Http messages.

Resource definitions

When processing your annotated interface you've created before, the endpoint registers it as a resource. A resource is an instance of Kix\Apiranha\ResourceDefinitionInterface, which basically contains all the data necessary to make an HTTP request to an API. You might think of it like a Swagger definition.

Router

A router is responsible for generating concrete URIs when given a resource and an array of parameters the request is executed with. The built-in router allows you to pass extra parameters you have not explicitly declared in a resource's path. Those will be added as query parameters.

Listeners

You can attach your own or built-in listeners to the lifecycle, which consists of three events:

  • Endpoint::LISTENER_BEFORE_REQUEST, used to modify the request before it has been sent,
  • Endpoint::LISTENER_AFTER_RESPONSE, which is used to attach serializers to the workflow,
  • Endpoint::LISTENER_AFTER_DATA, which is used to hydrate your model instances.

All of the listeners can be implemented as callables, and 'after response' and 'after data' listeners can also implement Kix\Apiranha\Listener\AfterResponseListenerInterface or Kix\Apiranha\Listener\AfterDataListenerInterface.

BEFORE_REQUEST listeners

A listener that is attached to the Endpoint::LISTENER_BEFORE_REQUEST receives just one argument: the ResponseInterface instance that has been instantiated for the current request. For example, you could use it to pass authorization headers to a secured API:

$endpoint = Builder::createEndpoint('http://api.github.com', [GithubApi::class]);

$endpoint->addListener(Endpoint::LISTENER_BEFORE_REQUEST, function (RequestInterface $request) {
    return $request->withAddedHeader('Authorization', 'Basic 12345');
});

AFTER_RESPONSE listeners

A listener attached to Endpoint::LISTENER_AFTER_RESPONSE could either be a callable that matches the signature of function(RequestInterface $request, ResponseInterface $response) or an instance of Kix\Apiranha\Listener\AfterResponseListenerInterface. For example, such a listener could be helpful in case when the HTTP client you are using does not throw for bad HTTP status codes.

Here's StatusCodeListener, for example:

class StatusCodeListener implements AfterResponseListenerInterface
{
    /**
     * @param RequestInterface  $request
     * @param ResponseInterface $response
     * @throws \Exception
     * @return void
     */
    public function process(RequestInterface $request, ResponseInterface $response)
    {
        if ($response->getStatusCode() > 400) {
            throw new \RuntimeException(sprintf(
                'Bad status code: %s',
                $response->getStatusCode()
            ));
        }
    }
}

Having the request also available allows you to log precise request/response interactions, for example.

Also note that you're allowed to return an instance of ApiResponse, which wraps the PSR response, but also has a data property which you can manipulate.

AFTER_DATA listeners

A listener attached to Endpoint::LISTENER_AFTER_DATA could either be a callable that matches the signature of function(ResponseInterface $response, ResourceDefinitionInterface $resource) or an instance of Kix\Apiranha\Listener\AfterDataListenerInterface.

Such listeners are used by the library to hydrate objects with data returned by the API we wrap.

Hydration

There are several options available for the hydration strategies:

  • Hydration via reflection, implemented in ReflectionHydratorListener
  • Hydration with ocramius/generated-hydrator, implemented in GeneratedHydratorListener

Reflection hydrator is always available, as long as PHP has reflection APIs available. Generated hydrator is an alternative implementation which could be more performant.

Both of these strategies are implemented as listeners and can be attached to the Endpoint::LISTENER_AFTER_DATA event:

$endpoint = new Endpoint($adapter, new Router(), 'http://api.github.com');
$endpoint->addListener(Endpoint::LISTENER_AFTER_DATA, new ReflectionHydratorListener());

TODO: benchmarks

Once the model instance has been hydrated, you get back an instance of the class you've declared as your model. And that's it!