Lullabot/mpx-php

View on GitHub
src/AuthenticatedClient.php

Summary

Maintainability
A
0 mins
Test Coverage
A
91%
<?php

namespace Lullabot\Mpx;

use GuzzleHttp\ClientInterface;
use GuzzleHttp\Promise\Promise;
use GuzzleHttp\Promise\PromiseInterface;
use Lullabot\Mpx\DataService\IdInterface;
use Lullabot\Mpx\Exception\ClientException;
use Lullabot\Mpx\Service\IdentityManagement\UserSession;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

class AuthenticatedClient implements ClientInterface
{
    /**
     * The user to establish a client for.
     *
     * @var UserSession
     */
    protected $userSession;

    /**
     * The client used to access MPX.
     *
     * @var Client
     */
    protected $client;

    /**
     * An optional account to use as the account context for requests.
     *
     * @see https://docs.theplatform.com/help/wsf-introduction-to-theplatforms-web-services#IntroductiontothePlatform%27sWebservices-Accountcontext
     *
     * @var \Lullabot\Mpx\DataService\IdInterface
     */
    protected $account;

    /**
     * The duration for authentication tokens to last for, or null for no
     * specific expiry.
     *
     * @var int
     */
    protected $duration;

    /**
     * Construct a new AuthenticatedClient.
     *
     * Note that the authentication is not actually established until
     * acquireToken is called.
     *
     * @param Client      $client      The client used to access MPX.
     * @param UserSession $userSession The user associated with this client.
     * @param IdInterface $account     (optional) The account to use as the account context for requests.
     */
    public function __construct(Client $client, UserSession $userSession, IdInterface $account = null)
    {
        $this->client = $client;
        $this->userSession = $userSession;

        if ($account) {
            $this->account = $account;
        }
    }

    public function send(RequestInterface $request, array $options = []): ResponseInterface
    {
        return $this->sendWithRetry($request, $options);
    }

    public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface
    {
        return $this->sendAsyncWithRetry($request, $options);
    }

    public function request($method, $uri, array $options = []): ResponseInterface
    {
        return $this->requestWithRetry($method, $uri, $options);
    }

    public function requestAsync($method, $uri, array $options = []): PromiseInterface
    {
        return $this->requestAsyncWithRetry($method, $uri, $options);
    }

    public function getConfig($option = null)
    {
        return $this->client->getConfig($option);
    }

    /**
     * Set the duration for token lifetimes.
     *
     * @param int|null $duration The duration in seconds, or null to not use a specific lifetime.
     */
    public function setTokenDuration(int $duration = null): void
    {
        $this->duration = $duration;
    }

    /**
     * Return if this client has an account context.
     */
    public function hasAccount(): bool
    {
        return isset($this->account);
    }

    /**
     * Return the account to use as the context for requests.
     *
     * @throws \LogicException Thrown if an account context is not set.
     */
    public function getAccount(): IdInterface
    {
        if (!$this->account) {
            throw new \LogicException('hasAccount() must return TRUE before calling getAccount()');
        }

        return $this->account;
    }

    /**
     * Merge authentication headers into request options.
     *
     * @param array $options The array of request options.
     * @param bool  $reset   Acquire a new token even if one is cached.
     *
     * @return array The updated request options.
     */
    private function mergeAuth(array $options, bool $reset = false): array
    {
        if (!isset($options['query'])) {
            $options['query'] = [];
        }
        $token = $this->userSession->acquireToken($this->duration, $reset);
        $options['query'] += [
            'token' => $token->getValue(),
        ];

        return $options;
    }

    /**
     * Send a request, retrying once if the authentication token is invalid.
     *
     * @param \Psr\Http\Message\RequestInterface $request The request to send.
     * @param array                              $options Request options to apply.
     */
    private function sendAsyncWithRetry(RequestInterface $request, array $options): PromiseInterface|RequestInterface
    {
        // This is the initial API request that we expect to pass.
        $merged = $this->mergeAuth($options);
        $inner = $this->client->sendAsync($request, $merged);

        // However, if it fails, we need to try a second request. We can't
        // create the second request method outside of the promise body as we
        // need a new invocation of mergeAuth() that resets the token.
        $outer = $this->outerPromise($inner);

        $inner->then(function ($value) use ($outer) {
            // The very first request worked, so resolve the outer promise.
            $outer->resolve($value);
        }, function ($e) use ($request, $options, $outer) {
            // Only retry if it's a token auth error.
            if (!$this->isTokenAuthError($e)) {
                $outer->reject($e);

                return;
            }

            $merged = $this->mergeAuth($options, true);
            $func = $this->client->send(...);
            $args = [$request, $merged];
            $this->finallyResolve($outer, $func, $args);
        });

        return $outer;
    }

    /**
     * Send a request, retrying once if the authentication token is invalid.
     *
     * @param \Psr\Http\Message\RequestInterface $request The request to send.
     * @param array                              $options An array of request options.
     *
     * @return \Psr\Http\Message\ResponseInterface
     */
    private function sendWithRetry(RequestInterface $request, array $options)
    {
        $merged = $this->mergeAuth($options);

        try {
            return $this->client->send($request, $merged);
        } catch (ClientException $e) {
            // Only retry if MPX has returned that the existing token is no
            // longer valid.
            if (!$this->isTokenAuthError($e)) {
                throw $e;
            }

            $merged = $this->mergeAuth($options, true);

            return $this->client->send($request, $merged);
        }
    }

    /**
     * Create and send a request, retrying once if the authentication token is invalid.
     *
     * @param string                                $method  HTTP method
     * @param string|\Psr\Http\Message\UriInterface $uri     URI object or string.
     * @param array                                 $options Request options to apply.
     */
    private function requestAsyncWithRetry(string $method, string|\Psr\Http\Message\UriInterface $uri, array $options): PromiseInterface|RequestInterface
    {
        // This is the initial API request that we expect to pass.
        $merged = $this->mergeAuth($options);
        $inner = $this->client->requestAsync($method, $uri, $merged);

        // However, if it fails, we need to try a second request. We can't
        // create the second request method outside of the promise body as we
        // need a new invocation of mergeAuth() that resets the token.
        $outer = $this->outerPromise($inner);

        $inner->then(function ($value) use ($outer) {
            // The very first request worked, so resolve the outer promise.
            $outer->resolve($value);
        }, function ($e) use ($method, $uri, $options, $outer) {
            // Only retry if it's a token auth error.
            if (!$this->isTokenAuthError($e)) {
                $outer->reject($e);

                return;
            }

            $merged = $this->mergeAuth($options, true);
            $func = $this->client->request(...);
            $args = [$method, $uri, $merged];
            $this->finallyResolve($outer, $func, $args);
        });

        return $outer;
    }

    /**
     * Determine if an MPX exception is due to a token authentication failure.
     */
    private function isTokenAuthError(\Exception $e): bool
    {
        return ($e instanceof ClientException) && 401 == $e->getCode();
    }

    /**
     * Resolve or reject a promise by invoking a callable.
     */
    private function finallyResolve(PromiseInterface $promise, callable $callable, $args)
    {
        try {
            // Since we must have blocked to get to this point, we now use
            // a blocking request to resolve things once and for all.
            $promise->resolve(\call_user_func_array($callable, $args));
        } catch (\Exception $e) {
            $promise->reject($e);
        }
    }

    /**
     * Create and send a request, retrying once if the authentication token is invalid.
     *
     * This method intentionally doesn't call requestAsyncWithRetry and wait()
     * on the promise, as we want to make sure the underlying sync client
     * methods are used.
     *
     * @param string                                $method  HTTP method
     * @param string|\Psr\Http\Message\UriInterface $uri     URI object or string.
     * @param array                                 $options Request options to apply.
     *
     * @return \Psr\Http\Message\ResponseInterface The response.
     */
    private function requestWithRetry(string $method, string|\Psr\Http\Message\UriInterface $uri, array $options)
    {
        try {
            $merged = $this->mergeAuth($options);

            return $this->client->request($method, $uri, $merged);
        } catch (ClientException $e) {
            // Only retry if MPX has returned that the existing token is no
            // longer valid.
            if (!$this->isTokenAuthError($e)) {
                throw $e;
            }

            $merged = $this->mergeAuth($options, true);

            return $this->client->request($method, $uri, $merged);
        }
    }

    /**
     * Return a new promise that waits on another promise.
     */
    private function outerPromise(PromiseInterface $inner): Promise
    {
        $outer = new Promise(function () use ($inner) {
            // Our wait function invokes the inner's wait function, as as far
            // as callers are concerned there is only one promise.
            try {
                $inner->wait();
            } catch (\Exception) {
                // The inner promise handles all rejections.
            }
        });

        return $outer;
    }
}