Newer
Older
* Copyright (C) 2019 Leipzig University Library
*
* 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 Laminas\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.';
protected $serializer;
protected $httpClient;
protected $uriFactory;
protected $streamFactory;
protected $requestFactory;
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:

Alexander Purr
committed
[$username, $password] = $credentials;
$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;
}
* @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
*
* @return Order
* @throws ClientException
*/
public function requestOrderCreation(Order $order): Order
{

Alexander Purr
committed
$order,
'json',
[
'groups' => ['order:creation:request'],
$request = $this->buildRequest('post', 'orders', $body);
$response = $this->sendAuthenticatedRequest($request);
if ($response->getStatusCode() !== 201) {
$this->throwException($response);
}
$result = $this->serializer->deserialize(
(string)$response->getBody(),

Alexander Purr
committed
Order::class,
'json',
['groups' => ['order:creation:response']]
/* refresh user details */
$this->requestUserDetails();

Alexander Purr
committed
return $result;
}
/**
* 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);
}
$record = $this->serializer->deserialize(
(string)$response->getBody(),

Alexander Purr
committed
Record::class,
'json'
* @return array|Order[]
* @throws ClientException
*/
public function requestOrderList(): array
{
$request = $this->buildRequest('get', 'orders');
$response = $this->sendAuthenticatedRequest($request);
if ($response->getStatusCode() !== 200) {
$this->throwException($response);
}
$list = $this->serializer->deserialize(
(string)$response->getBody(),

Alexander Purr
committed
Order::class . '[]',
'json'
$keys = array_map(
function (Order $order) {
return $order->getId();

Alexander Purr
committed
},
$list
return array_combine($keys, $list);
/**
* Get order by id
* TODO: Why can id be null?
*
* @param null $orderId order identifier
*
* @return Order|null
* @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);
}
$order = $this->serializer->deserialize(
(string)$response->getBody(),

Alexander Purr
committed
Order::class,
'json'
return $order;
}
/**
* Delete order
*
* @param int $orderId order id
* @return bool
*
* @throws ClientException
*/
public function requestOrderDelete(int $orderId) : bool
{
$request = $this->buildRequest('delete', "orders/{$orderId}");
$response = $this->sendAuthenticatedRequest($request);
if ($response->getStatusCode() !== 204) {
$this->throwException($response);
}
return true;
}
/**
* Update order
*
* @param Order $order order
*
* @return bool
* @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 {

Alexander Purr
committed
'baseUrl',
'username',
'firstname',
$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
* @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
public function requestUserDetails($userId = null, $forceReload = false): ?User
{
$logon = $this->restoreLogon();
$user = $this->session['user'] ?? null;
$ownId = $logon->getOwnerId();
if (null === $userId || $userId === $ownId) {
return $user;
}
$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);
}
$user = $this->serializer->deserialize(
(string)$response->getBody(),

Alexander Purr
committed
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
{

Alexander Purr
committed
$user,
'json',
[
'groups' => ['user:creation:request']
$request = $this->buildRequest('post', 'users', $body);
$response = $this->sendAuthenticatedRequest($request);
if ($response->getStatusCode() !== 201) {
$this->throwException($response);
}
$result = $this->serializer->deserialize(
(string)$response->getBody(),

Alexander Purr
committed
User::class,
'json',
['groups' => ['user:creation:response']]
return $result;
}
/**
* 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
{

Alexander Purr
committed
$user,
[
'user:update-username:request'
* @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);
}
$list = $this->serializer->deserialize(

Alexander Purr
committed
(string)$response->getBody(),
User::class . '[]',
'json'
$keys = array_map(
function (User $libary) {
return $libary->getId();

Alexander Purr
committed
},
$list
return $this->session['users'] = array_combine($keys, $list);
}
/**
* Empty user list from session
*
* @return void
*/
public function flushUserList()
{
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 UserNotLoggedinException
* @throws UserNotAuthorizedException
*/
protected function authorize(String $permission, User $user = null)
{
try {
$logon = $this->restoreLogon();
} catch (ClientException $exception) {
throw new UserNotLoggedinException();
}
throw new UserNotLoggedinException();
}
$user = $this->requestUserDetails();
if (!$user->hasPermission($permission)) {
throw new UserNotAuthorizedException();
}
}
/**
* Check if user is authorized
*
* @param String $permission name of permission
*
* @return bool
* @throws ClientException
*/
public function isAuthorized(String $permission)
{
try {
$this->authorize($permission);
} catch (UserAuthorizationException $exception) {
return false;
return true;
* @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);
}
$list = $this->serializer->deserialize(

Alexander Purr
committed
(string)$response->getBody(),
Library::class . '[]',
'json'
$keys = array_map(
function (Library $libary) {
return $libary->getId();

Alexander Purr
committed
},
$list
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
*
* @return Library
* @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);
}
$library = $this->serializer->deserialize(

Alexander Purr
committed
(string)$response->getBody(),
Library::class,
'json'
$phpTypeCasting = array_map(
function (Library $library) {
return $library->getId();

Alexander Purr
committed
},
[$library]
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);
}
$result = $this->serializer->deserialize(
(string)$response->getBody(),

Alexander Purr
committed
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) {
$this->refreshLogon($logon);
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'] ?? [], Request::HEADER_X_FORWARDED_ALL);
$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);
* @throws ClientException