Skip to content
Snippets Groups Projects
Client.php 27.1 KiB
Newer Older
 * Fidis client
 *
 * Copyright (C) 2019 Leipzig University Library
 *
 * PHP version 7
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License version 2 as
 * published by the Free Software Foundation.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * @category VuFind
 * @package  Service
 * @author   Sebastian Kehr <kehr@ub.uni-leipzig.de>
 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
 * @link     https://vufind.org/wiki/development Wiki
 */
namespace fid\Service;

use fid\Service\DataTransferObject\Library;
use fid\Service\DataTransferObject\Logon;
use fid\Service\DataTransferObject\Order;
use fid\Service\DataTransferObject\Record;
use fid\Service\DataTransferObject\User;
use InvalidArgumentException;
use Psr\Http\Client\ClientExceptionInterface as HttpClientExceptionInterface;
use Psr\Http\Client\ClientInterface as HttpClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\UriFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Serializer\SerializerInterface;
use VuFind\Cookie\CookieManager;
use Zend\Session\Container as Session;

/**
 * FID client class
 *
 * @category VuFind
 * @package  Service
 * @author   Sebastian Kehr <kehr@ub.uni-leipzig.de>
 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
 * @link     https://vufind.org/wiki/development Wiki
 */
class Client
{
    protected const ERRMSG_HTTPCLIENT
        = 'An unexpected http client error occured.';

    protected const ERRMSG_HTTPRESPONSE
        = 'An unexcected http response occured.';

    /* @var string */
    /* @var Session */
    /* @var CookieManager */
    /* @var SerializerInterface */
    /* @var HttpClientInterface */
    /* @var UriFactoryInterface */
    /* @var StreamFactoryInterface */
    protected $streamFactory;

    /* @var RequestFactoryInterface */
    protected $requestFactory;

    /* @var string */
    protected $locale = 'en';

    /* @var Client */
    public static $instance;

    /**
     * Client constructor.
     *
     * @param array                   $config         config
     * @param Session                 $session        session
     * @param CookieManager           $cookies        cookies
     * @param SerializerInterface     $serializer     serializer
     * @param HttpClientInterface     $httpClient     httpClient
     * @param UriFactoryInterface     $uriFactory     uriFactory
     * @param StreamFactoryInterface  $streamFactory  streamFactory
     * @param RequestFactoryInterface $requestFactory requestFactory
     */
    public function __construct(
        Session $session,
        CookieManager $cookies,
        SerializerInterface $serializer,
        HttpClientInterface $httpClient,
        UriFactoryInterface $uriFactory,
        StreamFactoryInterface $streamFactory,
        RequestFactoryInterface $requestFactory
    ) {
        $this->config = $config;
        $this->session = $session;
        $this->cookies = $cookies;
        $this->serializer = $serializer;
        $this->httpClient = $httpClient;
        $this->uriFactory = $uriFactory;
        $this->streamFactory = $streamFactory;
        $this->requestFactory = $requestFactory;
        Client::$instance = $this;
     * Set local for translations
     *
     * @param string $locale locale
     *
     * @return void
     */
    public function setLocale(string $locale): void
    {
        $this->locale = $locale;
    }

    /**
     * Get if user is logged in
     *
     * @return bool
     */
    public function isLoggedOn(): bool
    {
        try {
            return !!$this->restoreLogon();
        } catch (ClientException $exception) {
            return false;
        }
    }

    /**
     * Get user logon
     *
     * @param string ...$credentials credentials
     * @throws ClientException
     */
    public function logon(string ...$credentials): Logon
    {
        $this->logoff();

        switch (count($credentials)) {
        case 1:
            $credentials = base64_decode(urldecode($credentials[0]));
            $logon = $this->storeLogon($this->parseLogon($credentials));
            return $this->refreshLogon($logon);

        case 2:
            $body = json_encode(compact('username', 'password'));
            $request = $this->buildRequest('post', 'logons', $body);
            $response = $this->sendRequest($request);

            if ($response->getStatusCode() !== 201) {
                $this->throwException($response);
            }
            $logon = $this->parseLogon((string)$response->getBody());
            return $this->storeLogon($logon);
        default:
            throw new InvalidArgumentException();
     * Check credentials
     *
     * @param string $username username
     * @param string $password password
     *
     * @return bool
     */
    public function checkCredentials(string $username, string $password): bool
    {
        $body = json_encode(compact('username', 'password'));
        $request = $this->buildRequest('post', 'logons', $body);
        $response = $this->sendRequest($request);

        if ($response->getStatusCode() !== 201) {
            return false;
        }

        $logon = $this->parseLogon((string)$response->getBody());
        $this->storeLogon($logon);
        return true;
    }

     * Logout
     *
     * @return void
     *
     * @throws ClientException
     */
    public function logoff(): void
    {
        try {
            $logon = $this->restoreLogon();
        } catch (ClientException $exception) {
            return;
        }

        $username = $logon->getUsername();
        $request = $this->buildRequest('delete', "logons/$username");
        $this->cookies->set('finc_fid_logon', null);
        $this->session->exchangeArray([]);

        $response = $this->sendRequest($request);
        switch ($response->getStatusCode()) {
        case 404:
        case 204:
            break;
        default:
            $this->throwException($response);
     * Request creation of an order
     *
     * @param Order $order order
     * @throws ClientException
     */
    public function requestOrderCreation(Order $order): Order
    {
        $body = $this->serializer->serialize(
            'groups' => ['order:creation:request'],

        $request = $this->buildRequest('post', 'orders', $body);
        $response = $this->sendAuthenticatedRequest($request);

        if ($response->getStatusCode() !== 201) {
            $this->throwException($response);
        }
        /* @var Order $result */
        $result = $this->serializer->deserialize(
            (string)$response->getBody(),
            Order::class,
            'json',
            ['groups' => ['order:creation:response']]
        /* refresh user details */
        $this->requestUserDetails();
    /**
     * Get record by id
     *
     * @param string $recordid record_id
     *
     * @return Record
     *
     * @throws ClientException
     */
    public function requestRecord(string $recordid) : Record
    {
        $request = $this->buildRequest('get', "records/$recordid");
        $response = $this->sendAuthenticatedRequest($request);

        if ($response->getStatusCode() !== 200) {
            $this->throwException($response);
        }
        /* @var Record $record */
        $record = $this->serializer->deserialize(
            (string)$response->getBody(),
     * Get list of orders
     *
     * @throws ClientException
     */
    public function requestOrderList(): array
    {
        $request = $this->buildRequest('get', 'orders');
        $response = $this->sendAuthenticatedRequest($request);

        if ($response->getStatusCode() !== 200) {
            $this->throwException($response);
        }
        /* @var Order[] $list */
        $list = $this->serializer->deserialize(
            (string)$response->getBody(),
        $keys = array_map(
            function (Order $order) {
                return $order->getId();
     * Get order by id
     * TODO: Why can id be null?
     *
     * @param null $orderId order identifier
     *
     * @throws ClientException
     * @throws UserNotAuthorizedException
     */
    public function requestOrder($orderId = null): ?Order
    {
        $request = $this->buildRequest('get', "orders/$orderId");
        $response = $this->sendAuthenticatedRequest($request);

        if ($response->getStatusCode() !== 200) {
            $this->throwException($response);
        }

        /* @var Order $order */
        $order = $this->serializer->deserialize(
            (string)$response->getBody(),
     * Update order
     *
     * @param Order $order order
     *
     * @throws ClientException
     */
    public function requestOrderUpdate(Order $order) : bool
    {
        $body = $this->serializer->serialize(
            $order,
            'json',
            ['groups' => ['order:update:request']]
        );
        $request = $this->buildRequest('put', "orders/{$order->getId()}", $body);
        $response = $this->sendAuthenticatedRequest($request);

        if ($response->getStatusCode() !== 200) {
            $this->throwException($response);
            return false;
        }

        return true;
    }

     * Request registration link
     *
     * @param string $baseUrl   baseUrl
     * @param string $username  username
     * @param string $firstname firstname
     * @param string $lastname  lastname
     *
     * @return void
     *
     * @throws ClientException
     */
    public function requestRegistrationLink(
        string $baseUrl,
        string $username,
        string $firstname,
        string $lastname
    ): void {
        $body = json_encode(
            compact(
        $request = $this->buildRequest('post', 'mail/registration', $body);
        $response = $this->sendRequest($request);

        if ($response->getStatusCode() !== 204) {
            $this->throwException($response);
        }
    }

     * Request (new) password link by mail
     *
     * @param string $baseUrl  base url
     * @param string $username username
     *
     * @return void
     * @throws ClientException
     */
    public function requestPasswordLink(string $baseUrl, string $username): void
    {
        $body = json_encode(compact('baseUrl', 'username'));
        $request = $this->buildRequest('post', 'mail/password', $body);
        $response = $this->sendRequest($request);

        if ($response->getStatusCode() !== 204) {
            $this->throwException($response);
        }
    }

     * Request (new) username link by mail
     *
     * @param string $baseUrl  base url
     * @param string $username username
     *
     * @return void
     *
     * @throws ClientException
     */
    public function requestUsernameLink(string $baseUrl, string $username): void
    {
        $body = json_encode(compact('baseUrl', 'username'));
        $request = $this->buildRequest('post', 'mail/username', $body);
        $response = $this->sendAuthenticatedRequest($request);

        if ($response->getStatusCode() !== 204) {
            $this->throwException($response);
        }
    }
     * Get user details
     *
     * @param null $userId      user identifier
     * @param bool $forceReload force reload
     *
     * @throws ClientException
     * @throws UserNotAuthorizedException
     * @throws UserNotLoggedinException
    public function requestUserDetails($userId = null, $forceReload = false): ?User
    {
        $logon = $this->restoreLogon();

        /* @var User $user */
        $user = $this->session['user'] ?? null;
        $ownId = $logon->getOwnerId();

        if (null === $userId || $userId === $ownId) {
            // user asks for own profile data
            if (!empty($user) && !$forceReload) {
            $userId = $ownId;
        } else {
            // user asks for another user's profile
            // this shall only be possible for authorized admins
            $this->authorize('edit_user');
        }
        $request = $this->buildRequest('get', "users/$userId");
        $response = $this->sendAuthenticatedRequest($request);

        if ($response->getStatusCode() === 401) {
            throw new UserNotAuthorizedException();
        }

        if ($response->getStatusCode() !== 200) {
            $this->throwException($response);
        }

        /* @var User $user */
        $user = $this->serializer->deserialize(
            (string)$response->getBody(),
            User::class,
            'json',
            ['groups' => ['user:details:response']]
        if ($ownId === $userId) {
            $this->session['user'] = $user;
        }
        return $user;
     * Create new user
     *
     * @param User $user user data
     * @throws ClientException
     */
    public function requestUserCreation(User $user): User
    {
        $body = $this->serializer->serialize(
            'groups' => ['user:creation:request']

        $request = $this->buildRequest('post', 'users', $body);
        $response = $this->sendAuthenticatedRequest($request);

        if ($response->getStatusCode() !== 201) {
            $this->throwException($response);
        }
        /* @var User $result */
        $result = $this->serializer->deserialize(
            (string)$response->getBody(),
            User::class,
            'json',
            ['groups' => ['user:creation:response']]
     * Update user
     *
     * @param User $user user
     * @throws ClientException
     */
    public function requestUserUpdate(User $user): User
    {
        return $this->doRequestUserUpdate($user, ['user:update:request']);
    }
     * Update password
     *
     * @param User $user user
     * @throws ClientException
     */
    public function requestUserPasswordUpdate(User $user): User
    {
        return $this->doRequestUserUpdate(
            $user,
            ['user:update-password:request']
        );
     * Update username
     *
     * @param User $user user
     * @throws ClientException
     */
    public function requestUserUsernameUpdate(User $user): User
    {
        return $this->doRequestUserUpdate(
            'user:update-username:request'
     * Get list of users
     *
     * @return User[]
     * @throws ClientException
     * @throws UserNotAuthorizedException
     */
    public function requestUserList(): array
    {
        // user asks for another users' profiles
        // this shall only be possible for authorized admins
        $this->authorize('read_user_list');

        if ($list = $this->session['users'] ?? null) {
            return $list;
        }

        $request = $this->buildRequest('get', 'users');
        $response = $this->sendAuthenticatedRequest($request);

        if ($response->getStatusCode() !== 200) {
            $this->throwException($response);
        }
        /* @var Library[] $list */
        $list = $this->serializer->deserialize(
            (string)$response->getBody(),
            User::class . '[]',
            'json'
        $keys = array_map(
            function (User $libary) {
                return $libary->getId();

        return $this->session['users'] = array_combine($keys, $list);
    }

    /**
     * Empty user list from session
     *
     * @return void
     */
        unset($this->session['users']);
    }

    /**
     * Authorize user
     *
     * Throws an Exception in case the user does not have the requested permission
     * or the permission cannot be verified
     * @param String    $permission Name of the permission
     * @param User|null $user       user or null for the currently logged in user
     *
     * @return void
     *
     * @throws ClientException
     * @throws UserNotLoggedinException
     * @throws UserNotAuthorizedException
     */
Sebastian Kehr's avatar
Sebastian Kehr committed
    protected function authorize(String $permission, User $user = null)
    {
        try {
            $logon = $this->restoreLogon();
        } catch (ClientException $exception) {
            throw new UserNotLoggedinException();
        }
Sebastian Kehr's avatar
Sebastian Kehr committed
        if (!$logon || !$logon->getOwnerId()) {
            throw new UserNotLoggedinException();
        }
        $user = $this->requestUserDetails();
        if (!$user->hasPermission($permission)) {
            throw new UserNotAuthorizedException();
        }
    }

     * Check if user is authorized
     *
     * @param String $permission name of permission
    public function isAuthorized(String $permission)
    {
        try {
            $this->authorize($permission);
        } catch (UserAuthorizationException $exception) {
     * Get list of libraries
     *
     * @throws ClientException
     */
    public function requestLibraryList(): array
    {
        if ($list = $this->session->libraries[$this->locale] ?? null) {
            return $list;
        }

        $request = $this->buildRequest('get', 'libraries');
        $response = $this->sendAuthenticatedRequest($request);

        if ($response->getStatusCode() !== 200) {
            $this->throwException($response);
        }
        /* @var Library[] $list */
        $list = $this->serializer->deserialize(
            (string)$response->getBody(),
            Library::class . '[]',
            'json'
        $keys = array_map(
            function (Library $libary) {
                return $libary->getId();
        if (empty($this->session->libraries)) {
            $this->session->libraries = [];
        }

        return $this->session->libraries[$this->locale] = array_combine(
            $keys,
            $list
        );
     * Get library by id
     *
     * @param $id identifier
     *
     * @throws ClientException
     */
    public function requestLibraryById($id): Library
    {
        if ($library = $this->session->homeLibrary[$this->locale] ?? null) {
            return $library;
        }

        $request = $this->buildRequest('get', "libraries/$id");
        $response = $this->sendAuthenticatedRequest($request);

        if ($response->getStatusCode() !== 200) {
            $this->throwException($response);
        }

        /* @var Library $list */
        $library = $this->serializer->deserialize(
            (string)$response->getBody(),
            Library::class,
            'json'
        $phpTypeCasting = array_map(
            function (Library $library) {
                return $library->getId();

        if (empty($this->session->homeLibrary)) {
            $this->session->homeLibrary = [];
        }

        return $this->session->homeLibrary[$this->locale]
            = array_combine($phpTypeCasting, [$library])[$id];
     * Do update user
     *
     * @param User  $user   user
     * @param array $groups groups
     * @throws ClientException
     */
    protected function doRequestUserUpdate(User $user, array $groups): User
    {
        $body = $this->serializer->serialize($user, 'json', compact('groups'));
        $request = $this->buildRequest('put', "users/{$user->getId()}", $body);
        $response = $this->sendAuthenticatedRequest($request);

        if ($response->getStatusCode() !== 200) {
            $this->throwException($response);
        }
        /* @var User $result */
        $result = $this->serializer->deserialize(
            (string)$response->getBody(),
            User::class,
            'json',
            ['groups' => ['user:update:response']]
        $logon = $this->restoreLogon();
        if ($logon->getOwnerId() === $user->getId()) {
            // refresh user data
            $this->session['user'] = $result;
        }
        return $result;
     * Refresh logon
     *
     * @param Logon $logon logon
     *
     * @throws ClientException
     */
    protected function refreshLogon(Logon $logon): Logon
    {
        if (time() < $logon->getStalesAt()) {
            return $logon;
        }

        $username = $logon->getUsername();
        $password = $logon->getPassword();
        $body = json_encode(compact('username', 'password'));
        $request = $this->buildRequest('post', 'logons', $body);
        $response = $this->sendAuthenticatedRequest($request, false);

        if ($response->getStatusCode() === 401) {
            throw new UserNotAuthorizedException();
        }

        if ($response->getStatusCode() !== 201) {
            $this->throwException($response);
        }

        $logon = $this->parseLogon((string)$response->getBody());
        return $this->storeLogon($logon);
    }

    /**
     * Send request for authentication
     *
     * @param RequestInterface $request    request
     * @param bool             $retryOn401 retry if http status is Unauthorized
     *
     * @return ResponseInterface
     * @throws ClientException
     */
    protected function sendAuthenticatedRequest(
        RequestInterface $request,
        bool $retryOn401 = true
    ): ResponseInterface {
        $token = ($logon = $this->restoreLogon())->getToken();
        $request = $request->withHeader('Authorization', "Bearer $token");
        $response = $this->sendRequest($request);

        if ($response->getStatusCode() === 401 && $retryOn401) {
            return $this->sendAuthenticatedRequest($request, false);
        }

        return $response;
    }

    /**
     * Send request
     *
     * @param RequestInterface $request request
     *
     * @return ResponseInterface
     * @throws ClientException
     */
    protected function sendRequest(RequestInterface $request): ResponseInterface
    {
        try {
            return $this->httpClient->sendRequest($request);
        } catch (HttpClientExceptionInterface $exception) {
            throw new ClientException(self::ERRMSG_HTTPCLIENT, 0, $exception);
        }
    }

    /**
     * Build request object
     *
     * @param string $verb  verb
     * @param string $path  path
     * @param string $body  body
     * @param array  $query query
     *
     * @return RequestInterface
     */
    protected function buildRequest(
        string $verb,
        string $path,
        string $body = '',
        array $query = []
    ): RequestInterface {
        $baseUrl = $this->config['baseUrl'];
        $uri = $this->uriFactory->createUri("$baseUrl/$path")
            ->withQuery(http_build_query($query));

        $request = $this->requestFactory->createRequest($verb, $uri)
            ->withBody($this->streamFactory->createStream($body))
            ->withHeader('Content-type', 'application/json')
            ->withHeader('Accept', 'application/json')
            ->withHeader('Accept-language', $this->locale);

        if (APPLICATION_ENV === 'production') {
            return $request;
        }

        Request::setTrustedProxies($this->config['xdebug_trusted_proxies'] ?? []);
        $xdebugSession = $this->config['xdebug_session'] ?? 'fidis';
        $xdebugRemoteAddr = $this->config['xdebug_remote_addr']
            ?? Request::createFromGlobals()->getClientIp();

        return APPLICATION_ENV === 'production' ? $request
            : $request->withHeader('Cookie', "XDEBUG_SESSION=$xdebugSession")
            ->withHeader('X-xdebug-remote-addr', $xdebugRemoteAddr);
     * Throw exception
     *
     * @param ResponseInterface $response response
     *
     * @return void
     *
     * @throws ClientException
     */
    protected function throwException(ResponseInterface $response)
    {
        $errorCode = $response->getStatusCode();
        $error = json_decode((string)$response->getBody(), true);
        throw new ClientException($error['detail'], $errorCode);
     * Restore logon
     *
     * @throws ClientException
     */
    protected function restoreLogon(): Logon
    {
        /* @var Logon $logon */
        if ($logon = $this->session['logon'] ?? null) {
            $logon->setToken($this->cookies->get('finc_fid_logon'));
            if (time() < $logon->getExpiresAt()) {
                return $logon;
            }
        }

        $this->session->exchangeArray([]);
        $this->cookies->set('finc_fid_logon', null);
        throw new ClientException('Missing or expired logon.', 401);
    }

    /**
     * Store logon
     *
     * @param Logon $logon logon