diff --git a/Client/AccessTokenClient.php b/Client/AccessTokenClient.php new file mode 100644 index 0000000..1da9294 --- /dev/null +++ b/Client/AccessTokenClient.php @@ -0,0 +1,173 @@ + $apiId, + 'clientSecret' => $apiSecret, + 'scope' => $scopes, + 'redirectUri' => $redirectUri, + 'baseUri' => $authServiceUri, + ] + ); + + if ($logger !== null) { + $this->requestOptions = [ + 'on_stats' => function (TransferStats $stats) use ($logger) { + if ($stats->hasResponse()) { + $size = $stats->getHandlerStat('size_download') ?? 0; + $statusCode = $stats->getResponse() ? $stats->getResponse()->getStatusCode() : 0; + + $logger->info( + "Api request \"{$stats->getRequest()->getMethod()} {$stats->getRequest()->getRequestTarget()} HTTP/{$stats->getRequest()->getProtocolVersion()}\" {$statusCode} - {$size} - {$stats->getTransferTime()}" + ); + } else { + $logger->error( + "Api request: No response received with error {$stats->getHandlerErrorData()}" + ); + } + } + ]; + } + + $clientOptions['requestOptions'] = $this->requestOptions; + $this->provider = new BookboonProvider($clientOptions); + + $this->apiId = $apiId; + $this->cache = $cache; + $this->headers = $headers; + $this->act = $appUserId; + + $this->_apiUri = $this->parseUriOrDefault($apiUri); + } + + /** + * @param array $options + * @param string $type + * @return AccessTokenInterface + * @throws ApiAuthenticationException + * @throws UsageException + */ + public function requestAccessToken( + array $options = [], + string $type = OauthGrants::AUTHORIZATION_CODE + ) : AccessTokenInterface { + $provider = $this->provider; + + if ($type == OauthGrants::AUTHORIZATION_CODE && !isset($options["code"])) { + throw new UsageException("This oauth flow requires a code"); + } + + try { + $this->accessToken = $provider->getAccessToken($type, $options); + } + + catch (IdentityProviderException $e) { + //TODO: Parse and send this with exception (string) $e->getResponseBody()->getBody() + throw new ApiAuthenticationException("Authorization Failed"); + } + + return $this->accessToken; + } + + public function refreshAccessToken(AccessTokenInterface $accessToken) : AccessTokenInterface + { + $this->accessToken = $this->provider->getAccessToken('refresh_token', [ + 'refresh_token' => $accessToken->getRefreshToken() + ]); + + return $accessToken; + } + + public function generateState(): string + { + return $this->provider->generateRandomState(); + } + + public function isCorrectState(string $stateParameter, string $stateSession) : bool + { + if (empty($stateParameter) || ($stateParameter !== $stateSession)) { + throw new ApiInvalidStateException("State is invalid"); + } + + return true; + } + + public function getAuthorizationUrl(array $options = []): string + { + $provider = $this->provider; + + if (null != $this->act && false === isset($options['act'])) { + $options['act'] = $this->act; + } + + return $provider->getAuthorizationUrl($options); + } + + protected function parseUriOrDefault(?string $uri) : string + { + $protocol = ClientConstants::API_PROTOCOL; + $host = ClientConstants::API_HOST; + $path = ClientConstants::API_PATH; + + if (!empty($uri)) { + $parts = explode('://', $uri); + $protocol = $parts[0]; + $host = $parts[1]; + if (strpos($host, '/') !== false) { + throw new UsageException('URI must not contain forward slashes'); + } + } + + if ($protocol !== 'http' && $protocol !== 'https') { + throw new UsageException('Invalid protocol specified in URI'); + } + + return "${protocol}://${host}${path}"; + } + + public function getAct(): ?string { + return $this->act; + } +} diff --git a/Client/ClientConstants.php b/Client/ClientConstants.php new file mode 100644 index 0000000..4d3f5be --- /dev/null +++ b/Client/ClientConstants.php @@ -0,0 +1,21 @@ + $v) { + $this->offsetSet($k, $v); + } + + $this->set(static::HEADER_XFF, $this->getRemoteAddress() ?? ''); + } + + public function set(string $header, string $value) : void + { + $this->headers[$header] = $value; + } + + public function get(string $header) : ?string + { + return $this->headers[$header] ?? null; + } + + public function getAll() : array + { + $headers = []; + foreach ($this->headers as $h => $v) { + $headers[] = $h.': '.$v; + } + + return $headers; + } + + public function getHeadersArray() : array + { + return $this->headers; + } + + /** + * Returns the remote address either directly or if set XFF header value. + * + * @return string|null The ip address + */ + private function getRemoteAddress() : ?string + { + $hostname = null; + + if (isset($_SERVER['REMOTE_ADDR'])) { + $hostname = filter_var($_SERVER['REMOTE_ADDR'], FILTER_VALIDATE_IP); + + if (false === $hostname) { + $hostname = null; + } + } + + if (function_exists('apache_request_headers')) { + $headers = apache_request_headers(); + + if ($headers === false) { + return $hostname; + } + + foreach ($headers as $k => $v) { + if (strcasecmp($k, 'x-forwarded-for')) { + continue; + } + + $hostname = explode(',', $v); + $hostname = trim($hostname[0]); + break; + } + } + + return $hostname; + } + + /** + * Whether a offset exists + * @link https://php.net/manual/en/arrayaccess.offsetexists.php + * @param mixed $offset

+ * An offset to check for. + *

+ * @return bool true on success or false on failure. + *

+ *

+ * The return value will be casted to boolean if non-boolean was returned. + * @since 5.0.0 + */ + public function offsetExists($offset) + { + return isset($this->headers[strtolower($offset)]); + } + + /** + * Offset to retrieve + * @link https://php.net/manual/en/arrayaccess.offsetget.php + * @param mixed $offset

+ * The offset to retrieve. + *

+ * @return mixed Can return all value types. + * @since 5.0.0 + */ + public function offsetGet($offset) + { + return $this->headers[strtolower($offset)] ?? null; + } + + /** + * Offset to set + * @link https://php.net/manual/en/arrayaccess.offsetset.php + * @param mixed $offset

+ * The offset to assign the value to. + *

+ * @param mixed $value

+ * The value to set. + *

+ * @return void + * @since 5.0.0 + */ + public function offsetSet($offset, $value) + { + if (is_string($offset) && $offset !== '') { + $this->headers[strtolower($offset)] = $value; + } + } + + /** + * Offset to unset + * @link https://php.net/manual/en/arrayaccess.offsetunset.php + * @param mixed $offset

+ * The offset to unset. + *

+ * @return void + * @since 5.0.0 + */ + public function offsetUnset($offset) + { + unset($this->headers[strtolower($offset)]); + } +} diff --git a/Client/RawClient.php b/Client/RawClient.php new file mode 100644 index 0000000..1eb9994 --- /dev/null +++ b/Client/RawClient.php @@ -0,0 +1,167 @@ +_mappings = $mappings; + $this->_client = new Client(['handler' => $stack]); + $this->_serializer = $serializer; + $this->cache = $cache; + } + + public function getAll(string $class, array $params, bool $useCache): string + { + $url = $this->getUrl($class, $params); + $key = self::CACHE_KEY . $url; + + if ($useCache && $this->cache->has($key)) { + return $this->cache->get($key); + } + + $resp = $this->makeRequest("GET", $url); + $json = $resp->getBody()->getContents(); + + if ($useCache) { + $this->cache->set($key, $json); + } + + return $json; + } + + public function getById(string $id, string $class, array $params, bool $useCache): string + { + $url = $this->getUrl($class, $params, $id); + $key = $this->getCacheKey($id); + + if ($useCache && $this->cache->has($key)) { + return $this->cache->get($key); + } + + $resp = $this->makeRequest("GET", $url); + $json = $resp->getBody()->getContents(); + + if ($useCache) { + $this->cache->set($key, $json); + } + + return $json; + } + + public function create($obj, array $params): string + { + $url = $this->getUrl($obj, $params); + $jsonContents = $this->_serializer->serialize($obj, JsonLDEncoder::FORMAT); + $resp = $this->makeRequest("POST", $url, [ + RequestOptions::BODY => $jsonContents, + RequestOptions::HEADERS => [ + 'Content-Type' => 'application/json' + ] + ]); + + return $resp->getBody()->getContents(); + } + + public function update($obj, array $params): string + { + $id = method_exists($obj, 'GetId') ? $obj->GetId() : ''; + $url = $this->getUrl($obj, $params, $id); + $jsonContents = $this->_serializer->serialize($obj, JsonLDEncoder::FORMAT); + $resp = $this->makeRequest("POST", $url, [ + RequestOptions::BODY => $jsonContents, + RequestOptions::HEADERS => [ + 'Content-Type' => 'application/json' + ] + ]); + + $this->cache->delete($this->getCacheKey($id)); + return $resp->getBody()->getContents(); + } + + public function delete($obj, array $params): string + { + $id = method_exists($obj, 'GetId') ? $obj->GetId() : ''; + $url = $this->getUrl($obj, $params, $id); + $jsonContents = $this->_serializer->serialize($obj, JsonLDEncoder::FORMAT); + $resp = $this->makeRequest("DELETE", $url, [ + RequestOptions::BODY => $jsonContents, + RequestOptions::HEADERS => [ + 'Content-Type' => 'application/json' + ] + ]); + + $this->cache->delete($this->getCacheKey($id)); + return $resp->getBody()->getContents(); + } + + /** + * @param AccessTokenInterface|null $accessToken + */ + public function setAccessToken(?AccessTokenInterface $accessToken): void + { + $this->accessToken = $accessToken; + } + + protected function makeRequest(string $method, $url, array $options = []) { + if (isset($this->accessToken)) { + if (!isset($options[RequestOptions::HEADERS])) { + $options[RequestOptions::HEADERS] = []; + } + + $options[RequestOptions::HEADERS]['Authorization'] = 'Bearer: ' . $this->accessToken->getToken(); + } + + return $this->_client->request($method, $url, $options); + } + + protected function getUrl($class, array $params, string $id = '') + { + if (is_object($class)) { + $class = get_class($class); + } + + $endpoint = $this->_mappings->findEndpointByClass($class); + $url = $endpoint->getUrl($params); + + if ($id !== '' && !$endpoint->isSingleton()) { + $url = "$url/$id"; + } + + if (!empty($params)) { + asort($params); + $url = $url . "?" . http_build_query($params); + } + + return $url; + } + + protected function getCacheKey(string $id): string + { + return self::CACHE_KEY . $id; + } +} diff --git a/DependencyInjection/BookboonApiExtension.php b/DependencyInjection/BookboonApiExtension.php index e521c1c..4731d6e 100644 --- a/DependencyInjection/BookboonApiExtension.php +++ b/DependencyInjection/BookboonApiExtension.php @@ -2,8 +2,6 @@ namespace Bookboon\ApiBundle\DependencyInjection; -use Bookboon\Api\Cache\Cache; -use Bookboon\ApiBundle\Configuration\ApiConfiguration; use Bookboon\ApiBundle\Helper\ConfigurationHolder; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -12,7 +10,6 @@ class BookboonApiExtension extends Extension { - /** * Loads a specific configuration. * @@ -23,14 +20,17 @@ class BookboonApiExtension extends Extension */ public function load(array $configs, ContainerBuilder $container) { - $config = $this->processConfiguration($this->getConfiguration($configs, $container), $configs); + $holder = $this->getConfiguration($configs, $container); + if (!$holder) { + throw new \InvalidArgumentException("nulled config"); + } + + $config = $this->processConfiguration($holder, $configs); $container->register(ConfigurationHolder::class, ConfigurationHolder::class) ->addArgument($config) ->setPublic(false); - $container->setAlias(Cache::class, $config['cache_service']); - $loader = new YamlFileLoader( $container, new FileLocator(__DIR__ . '/../Resources/config') @@ -48,4 +48,4 @@ public function getXsdValidationBasePath() { return 'http://bookboon.com/schema/dic/' . $this->getAlias(); } -} \ No newline at end of file +} diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 9363079..23483f1 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -2,17 +2,11 @@ namespace Bookboon\ApiBundle\DependencyInjection; -use Bookboon\Api\Cache\RedisCache; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; class Configuration implements ConfigurationInterface { - /** - * Generates the configuration tree builder. - * - * @return \Symfony\Component\Config\Definition\Builder\TreeBuilder The tree builder - */ public function getConfigTreeBuilder() { $treeBuilder = new TreeBuilder('bookboonapi'); @@ -27,7 +21,6 @@ public function getConfigTreeBuilder() ->scalarNode('currency')->end() ->scalarNode('impersonator_id')->defaultNull()->end() ->scalarNode('redirect')->defaultNull()->end() - ->scalarNode('cache_service')->defaultValue(RedisCache::class)->end() ->arrayNode('languages')->isRequired()->prototype('scalar')->end()->end() ->arrayNode('scopes')->isRequired()->prototype('scalar')->end()->end() ->integerNode('premium_level')->end() diff --git a/Exception/ApiAuthenticationException.php b/Exception/ApiAuthenticationException.php new file mode 100644 index 0000000..8655455 --- /dev/null +++ b/Exception/ApiAuthenticationException.php @@ -0,0 +1,7 @@ +_config = $config; } - /** - * @return string - */ - public function getId() + public function getId(): string { - return $this->_config['id']; + return $this->_config['id'] ?? ''; } - /** - * @return string - */ - public function getSecret() + public function getSecret(): string { - return $this->_config['secret']; + return $this->_config['secret'] ?? ''; } - /** - * @return array - */ - public function getLanguages() + public function getLanguages(): array { - return $this->_config['languages']; + return $this->_config['languages'] ?? []; } - /** - * @return array - */ - public function getScopes() + public function getScopes(): array { - return $this->_config['scopes']; + return $this->_config['scopes'] ?? []; } - /** - * @return string - */ - public function getBranding() + public function getBranding(): ?string { - return isset($this->_config['branding']) ? $this->_config['branding'] : null; + return $this->_config['branding'] ?? null; } - /** - * @return string - */ - public function getRotation() + public function getRotation(): ?string { - return isset($this->_config['rotation']) ? $this->_config['rotation'] : null; + return $this->_config['rotation'] ?? null; } - /** - * @return string - */ - public function getCurrency() + public function getCurrency(): ?string { - return isset($this->_config['currency']) ? $this->_config['currency'] : null; + return $this->_config['currency'] ?? null; } - /** - * @return string - */ - public function getImpersonatorId() + public function getImpersonatorId(): ?string { - return isset($this->_config['impersonator_id']) ? $this->_config['impersonator_id'] : null; + return $this->_config['impersonator_id'] ?? null; } - - /** - * @return string - */ - public function getRedirectUrl() + public function getRedirectUrl(): ?string { - return isset($this->_config['redirect']) ? $this->_config['redirect'] : null; + return $this->_config['redirect'] ?? null; } - /** - * @return integer - */ - public function getPremiumLevel() + public function getPremiumLevel(): ?string { - return isset($this->_config['premium_level']) ? $this->_config['premium_level'] : null; + return $this->_config['premium_level'] ?? null; } - /** - * @return string|null - */ - public function getOverrideApiUri() + public function getOverrideApiUri(): ?string { - return isset($this->_config['override_api_uri']) ? $this->_config['override_api_uri'] : null; + return $this->_config['override_api_uri'] ?? null; } - /** - * @return string|null - */ - public function getOverrideAuthUri() + public function getOverrideAuthUri(): ?string { - return isset($this->_config['override_auth_uri']) ? $this->_config['override_auth_uri'] : null; + return $this->_config['override_auth_uri'] ?? null; } } \ No newline at end of file diff --git a/Helper/GuzzleDecorator.php b/Helper/GuzzleDecorator.php new file mode 100644 index 0000000..646d442 --- /dev/null +++ b/Helper/GuzzleDecorator.php @@ -0,0 +1,46 @@ +push(Middleware::mapRequest(function (RequestInterface $request) use ($config) { + $requestOut = $request + ->withHeader(Headers::HEADER_LANGUAGE, self::createAcceptLanguageString($config->getLanguages())); + + if (null !== $branding = $config->getBranding()) { + $requestOut = $requestOut->withHeader(Headers::HEADER_BRANDING, $branding); + } + if (null !== $rotation = $config->getRotation()) { + $requestOut = $requestOut->withHeader(Headers::HEADER_ROTATION, $rotation); + } + if (null !== $currency = $config->getCurrency()) { + $requestOut = $requestOut->withHeader(Headers::HEADER_CURRENCY, $currency); + } + if (null !== $premiumLevel = $config->getPremiumLevel()) { + $requestOut = $requestOut->withHeader(Headers::HEADER_PREMIUM, $premiumLevel); + } + + return $requestOut; + })); + + return $stack; + } + + private static function createAcceptLanguageString(array $languages) : string + { + $acceptLanguage = ''; + for ($i = 0, $iMax = count($languages); $iMax > $i; $i++) { + /* TODO: logic might need to be updated if $i > 10 */ + $acceptLanguage .= $i === 0 ? $languages[$i] . ',' : $languages[$i] . ';q=' . (1 - $i / 10) . ','; + } + return rtrim($acceptLanguage, ','); + } +} diff --git a/Resources/config/services.yml b/Resources/config/services.yml index 8e60d37..1f5543f 100644 --- a/Resources/config/services.yml +++ b/Resources/config/services.yml @@ -11,14 +11,27 @@ services: class: GuzzleHttp\HandlerStack factory: ['GuzzleHttp\HandlerStack', 'create'] - Bookboon\Api\Bookboon: - public: true - lazy: true - factory: ['Bookboon\ApiBundle\Service\ApiFactory', create] - arguments: - - '@Bookboon\ApiBundle\Helper\ConfigurationHolder' - - '@bookboon.cache' - - '@monolog.logger.api' - - '@bookboon.handlerstack' - tags: - - { name: monolog.logger, channel: api } + Bookboon\ApiBundle\Client\AccessTokenClient: + public: true + lazy: true + factory: [ 'Bookboon\ApiBundle\Service\ApiFactory', createOauth ] + arguments: + - '@Bookboon\ApiBundle\Helper\ConfigurationHolder' + - '@bookboon.cache' + - '@monolog.logger.api' + - '@bookboon.handlerstack' + tags: + - { name: monolog.logger, channel: api } + + Bookboon\ApiBundle\Client\RawClient: + public: true + autowire: true + bind: + GuzzleHttp\HandlerStack: '@jsonldclient.handlerstack' + + Bookboon\ApiBundle\Helper\GuzzleDecorator: + decorates: jsonldclient.handlerstack + factory: ['Bookboon\ApiBundle\Helper\GuzzleDecorator', 'decorate'] + bind: + GuzzleHttp\HandlerStack: '@.inner' + Bookboon\ApiBundle\Helper\ConfigurationHolder: '@Bookboon\ApiBundle\Helper\ConfigurationHolder' diff --git a/Service/ApiFactory.php b/Service/ApiFactory.php index 737ce5e..63fdb35 100644 --- a/Service/ApiFactory.php +++ b/Service/ApiFactory.php @@ -2,57 +2,47 @@ namespace Bookboon\ApiBundle\Service; -use Bookboon\Api\Bookboon; -use Bookboon\Api\Client\Headers; -use Bookboon\Api\Client\Oauth\OauthGrants; -use Bookboon\Api\Client\OauthClient; +use Bookboon\ApiBundle\Client\Headers; +use Bookboon\OauthClient\OauthGrants; +use Bookboon\ApiBundle\Client\AccessTokenClient; use Bookboon\ApiBundle\Helper\ConfigurationHolder; use GuzzleHttp\HandlerStack; +use League\OAuth2\Client\Token\AccessTokenInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; class ApiFactory { - /** - * @param ConfigurationHolder $config - * @param CacheInterface $cache - * @param LoggerInterface $logger - * @return Bookboon - * @throws \Bookboon\Api\Exception\UsageException - */ - public static function create( + public static function createOauth( ConfigurationHolder $config, - CacheInterface $cache, - LoggerInterface $logger, - HandlerStack $stack - ) { - $bookboon = new Bookboon( - new OauthClient( - $config->getId(), - $config->getSecret(), - self::headersFromConfig($config), - $config->getScopes(), - $cache, - '', - null, - $config->getOverrideAuthUri(), - $config->getOverrideApiUri(), - $logger, - ['handler' => $stack] - ) + CacheInterface $cache, + LoggerInterface $logger, + HandlerStack $stack + ) : AccessTokenClient { + return new AccessTokenClient( + $config->getId(), + $config->getSecret(), + new Headers(), + $config->getScopes(), + $cache, + '', + null, + $config->getOverrideAuthUri(), + $config->getOverrideApiUri(), + $logger, + ['handler' => $stack] ); - - $bookboon->getClient()->setAccessToken(self::credentialFactory($bookboon, $cache, $config)); - - return $bookboon; } - public static function credentialFactory(Bookboon $bookboon, CacheInterface $cache, ConfigurationHolder $config) - { + public static function credentialFactory( + AccessTokenClient $oauth, + CacheInterface $cache, + ConfigurationHolder $config + ) : AccessTokenInterface { $token = $cache->get("bookboonapi.{$config->getId()}"); if ($token === null) { - $token = $bookboon->getClient()->requestAccessToken([], OauthGrants::CLIENT_CREDENTIALS); + $token = $oauth->requestAccessToken([], OauthGrants::CLIENT_CREDENTIALS); $ttl = $token->getExpires() - time(); $cache->set("bookboonapi.{$config->getId()}", $token, $ttl); @@ -60,47 +50,4 @@ public static function credentialFactory(Bookboon $bookboon, CacheInterface $cac return $token; } - - /** - * @param ConfigurationHolder $config - * @return Headers - */ - private static function headersFromConfig(ConfigurationHolder $config) - { - $headers = new Headers(); - - $headers->set(Headers::HEADER_LANGUAGE, self::createAcceptLanguageString($config->getLanguages())); - - if (!empty($config->getBranding())) { - $headers->set(Headers::HEADER_BRANDING, $config->getBranding()); - } - - if (!empty($config->getRotation())) { - $headers->set(Headers::HEADER_ROTATION, $config->getRotation()); - } - - if (!empty($config->getCurrency())) { - $headers->set(Headers::HEADER_CURRENCY, $config->getCurrency()); - } - - if (!empty($config->getPremiumLevel())) { - $headers->set(Headers::HEADER_PREMIUM, $config->getPremiumLevel()); - } - - return $headers; - } - - /** - * @param $languages - * @return string - */ - private static function createAcceptLanguageString($languages) - { - $acceptLanguage = ''; - for ($i=0, $iMax = count($languages); $iMax > $i; $i++) { - /* TODO: logic might need to be updated if $i > 10 */ - $acceptLanguage .= $i === 0 ? $languages[$i] . ',' : $languages[$i] . ';q=' . (1 - $i/10) . ','; - } - return rtrim($acceptLanguage,','); - } -} \ No newline at end of file +} diff --git a/composer.json b/composer.json index 8d609f0..f51f14c 100644 --- a/composer.json +++ b/composer.json @@ -16,9 +16,12 @@ "require": { "php": ">=7.4", "symfony/dependency-injection": "^4.0|^5.0", + "symfony/http-kernel": "^4.0|^5.0", "symfony/config": "^4.0|^5.0", "symfony/cache": "^4.0|^5.0", - "bookboon/api": "~4.12" + "bookboon/jsonld-client": "^2.0", + "bookboon/api-models-php": "^1.1", + "bookboon/oauth2-client-php": "^1.0" }, "require-dev": { "vimeo/psalm": "^4.1" diff --git a/psalm.xml b/psalm.xml index c1e8fbb..c9cd84d 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,17 +1,15 @@ - - - - + +