diff --git a/libs/azure-api-client/README.md b/libs/azure-api-client/README.md index ee0019bf3..ef2f7f28b 100644 --- a/libs/azure-api-client/README.md +++ b/libs/azure-api-client/README.md @@ -1,39 +1,51 @@ # Azure API Client ## Installation - ```bash composer require keboola/azure-api-client ``` ## Usage - -To create API client using PHP: +The simplest way to use API client is just by creating its instance. ```php -# example.php -use Keboola\AzureApiClient\Authentication\AuthenticatorFactory; -use Keboola\AzureApiClient\ApiClientFactory\PlainAzureApiClientFactory; -use Keboola\AzureApiClient\GuzzleClientFactory; use Keboola\AzureApiClient\Marketplace\MarketplaceApiClient; use Monolog\Logger; -$authenticatorFactory = new AuthenticatorFactory(); -$clientFactory = new PlainAzureApiClientFactory($guzzleClientFactory, $authenticatorFactory, $logger); - -$marketingApiClient = MarketplaceApiClient::create($clientFactory); +$logger = new Logger('azure-api-client'); +$marketplaces = new MarketplaceApiClient([ + 'logger' => $logger, +]); ``` -To create API client using Symfony container, just register class services and everything should auto-wire just fine: -```yaml -# services.yaml -services: - Keboola\AzureApiClient\Authentication\AuthenticatorFactory: +### Authentication +By default, API client will try to authenticate using ENV variables `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` and +`AZURE_CLIENT_SECRET`. If some of them is not available, it'll fall back to Azure metadata API. + +If you want to supply your own credentials, you can pass custom authenticator instance in the client options: +```php +use Keboola\AzureApiClient\Authentication\Authenticator\ClientCredentialsAuthenticator; +use Keboola\AzureApiClient\Marketplace\MarketplaceApiClient; + +$logger = new Logger('azure-api-client'); +$marketplaces = new MarketplaceApiClient([ + 'authenticator' => new ClientCredentialsAuthenticator( + 'tenant-id', + 'client-id', + 'client-secret', + ), +]); +``` - Keboola\AzureApiClient\AzureApiClientFactory: +Or can provide custom authentication token directly: +```php +use Keboola\AzureApiClient\Authentication\Authenticator\StaticTokenAuthenticator; +use Keboola\AzureApiClient\Marketplace\MarketplaceApiClient; - Keboola\AzureApiClient\Marketplace\MarketplaceApiClient: - factory: ['App\Marketplace\Azure\MarketplaceApiClient\Marketplace\MarketplaceApiClient', 'create'] +$logger = new Logger('azure-api-client'); +$marketplaces = new MarketplaceApiClient([ + 'authenticator' => new StaticTokenAuthenticator('my-token'), +]); ``` ## License diff --git a/libs/azure-api-client/composer.json b/libs/azure-api-client/composer.json index 76bff7f3d..a8a5dd3c7 100644 --- a/libs/azure-api-client/composer.json +++ b/libs/azure-api-client/composer.json @@ -23,7 +23,7 @@ "php": "^8.1", "guzzlehttp/guzzle": "^7.5", "monolog/monolog": "^2.0|^3.0", - "symfony/validator": "^5.0|^6.0" + "webmozart/assert": "^1.11" }, "config": { "lock": false, @@ -38,6 +38,7 @@ "keboola/coding-standard": "^14.0", "phpstan/phpstan": "^1.9", "phpstan/phpstan-phpunit": "^1.3", + "phpstan/phpstan-webmozart-assert": "^1.2", "sempro/phpunit-pretty-print": "^1.4", "symfony/http-client": "^6.2" }, diff --git a/libs/azure-api-client/phpstan.neon b/libs/azure-api-client/phpstan.neon index 17c041465..87d678836 100644 --- a/libs/azure-api-client/phpstan.neon +++ b/libs/azure-api-client/phpstan.neon @@ -7,3 +7,4 @@ parameters: includes: - vendor/phpstan/phpstan-phpunit/extension.neon + - vendor/phpstan/phpstan-webmozart-assert/extension.neon diff --git a/libs/azure-api-client/src/ApiClient.php b/libs/azure-api-client/src/ApiClient.php index 11dcee873..787a2e92c 100644 --- a/libs/azure-api-client/src/ApiClient.php +++ b/libs/azure-api-client/src/ApiClient.php @@ -11,15 +11,16 @@ use GuzzleHttp\MessageFormatter; use GuzzleHttp\Middleware; use JsonException; +use Keboola\AzureApiClient\Authentication\Authenticator\AuthenticatorInterface; +use Keboola\AzureApiClient\Authentication\Authenticator\SystemAuthenticatorResolver; +use Keboola\AzureApiClient\Authentication\AuthorizationHeaderResolver; use Keboola\AzureApiClient\Exception\ClientException; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; -use Symfony\Component\Validator\Constraints as Assert; -use Symfony\Component\Validator\ConstraintViolationInterface; -use Symfony\Component\Validator\Validation; use Throwable; +use Webmozart\Assert\Assert; class ApiClient { @@ -28,76 +29,44 @@ class ApiClient private readonly HandlerStack $requestHandlerStack; private readonly GuzzleClient $httpClient; - private readonly LoggerInterface $logger; + private readonly AuthenticatorInterface $authenticator; /** - * @param array{ - * baseUrl?: null|non-empty-string, - * backoffMaxTries?: null|int<0, max>, - * middleware?: null|list, - * requestHandler?: null|callable, - * logger?: null|LoggerInterface, - * } $options + * @param non-empty-string|null $baseUrl + * @param int<0, max>|null $backoffMaxTries */ public function __construct( - array $options = [], + ?string $baseUrl = null, + ?int $backoffMaxTries = null, + ?AuthenticatorInterface $authenticator = null, + ?callable $requestHandler = null, + ?LoggerInterface $logger = null, ) { - $errors = Validation::createValidator()->validate($options, new Assert\Collection([ - 'allowMissingFields' => true, - 'allowExtraFields' => false, - 'fields' => [ - 'baseUrl' => new Assert\Sequentially([ - new Assert\Url(), - new Assert\Length(['min' => 1]), - ]), - - 'backoffMaxTries' => new Assert\Sequentially([ - new Assert\Type('int'), - new Assert\GreaterThanOrEqual(0), - ]), - - 'middleware' => new Assert\All([ - new Assert\Type('callable'), - ]), - - 'requestHandler' => new Assert\Type('callable'), - - 'logger' => new Assert\Type(LoggerInterface::class), - ], - ])); - - if ($errors->count() > 0) { - $messages = array_map( - fn(ConstraintViolationInterface $error) => sprintf( - '%s: %s', - $error->getPropertyPath(), - $error->getMessage() - ), - iterator_to_array($errors), - ); - - throw new ClientException(sprintf('Invalid options when creating client: %s', implode("\n", $messages))); - } + $backoffMaxTries ??= self::DEFAULT_BACKOFF_RETRIES; + $logger ??= new NullLogger(); - $this->logger = $options['logger'] ?? new NullLogger(); + Assert::nullOrMinLength($baseUrl, 1); + Assert::greaterThanEq($backoffMaxTries, 0); - $this->requestHandlerStack = HandlerStack::create($options['requestHandler'] ?? null); - foreach ($options['middleware'] ?? [] as $middleware) { - $this->requestHandlerStack->push($middleware); - } + $this->authenticator = $authenticator ?? new SystemAuthenticatorResolver( + backoffMaxTries: $backoffMaxTries, + requestHandler: $requestHandler ? $requestHandler(...) : null, + logger: $logger, + ); + + $this->requestHandlerStack = HandlerStack::create($requestHandler); - $backoffMaxTries = $options['backoffMaxTries'] ?? self::DEFAULT_BACKOFF_RETRIES; if ($backoffMaxTries > 0) { - $this->requestHandlerStack->push(Middleware::retry(new RetryDecider($backoffMaxTries, $this->logger))); + $this->requestHandlerStack->push(Middleware::retry(new RetryDecider($backoffMaxTries, $logger))); } - $this->requestHandlerStack->push(Middleware::log($this->logger, new MessageFormatter( + $this->requestHandlerStack->push(Middleware::log($logger, new MessageFormatter( '{hostname} {req_header_User-Agent} - [{ts}] "{method} {resource} {protocol}/{version}"' . ' {code} {res_header_Content-Length}' ))); $this->httpClient = new GuzzleClient([ - 'base_uri' => $options['baseUrl'] ?? null, + 'base_uri' => $baseUrl, 'handler' => $this->requestHandlerStack, 'headers' => [ 'User-Agent' => self::USER_AGENT, @@ -107,6 +76,17 @@ public function __construct( ]); } + public function authenticate(string $resource): void + { + $middleware = Middleware::mapRequest(new AuthorizationHeaderResolver( + $this->authenticator, + $resource + )); + + $this->requestHandlerStack->remove('auth'); + $this->requestHandlerStack->push($middleware, 'auth'); + } + /** * @template TResponseClass of ResponseModelInterface * @param class-string $responseClass diff --git a/libs/azure-api-client/src/ApiClientFactory/AuthenticatedAzureApiClientFactory.php b/libs/azure-api-client/src/ApiClientFactory/AuthenticatedAzureApiClientFactory.php deleted file mode 100644 index c7e88fbc8..000000000 --- a/libs/azure-api-client/src/ApiClientFactory/AuthenticatedAzureApiClientFactory.php +++ /dev/null @@ -1,43 +0,0 @@ -options, $options); - $options['baseUrl'] = $baseUrl; - - $options['middleware'] ??= []; - $options['middleware'][] = Middleware::mapRequest(new AuthorizationHeaderResolver( - $this->authenticatorFactory, - $resource - )); - - return new ApiClient($options); - } -} diff --git a/libs/azure-api-client/src/ApiClientFactory/AuthorizationHeaderResolver.php b/libs/azure-api-client/src/ApiClientFactory/AuthorizationHeaderResolver.php deleted file mode 100644 index ff78f2d4d..000000000 --- a/libs/azure-api-client/src/ApiClientFactory/AuthorizationHeaderResolver.php +++ /dev/null @@ -1,57 +0,0 @@ -isTokenValid()) { - $this->refreshToken(); - } - assert($this->token !== null); - - return $request->withHeader('Authorization', 'Bearer ' . $this->token->accessToken); - } - - private function isTokenValid(): bool - { - if ($this->token === null) { - return false; - } - - $expirationTimestamp = $this->token->accessTokenExpiration->getTimestamp(); - if ($expirationTimestamp - self::EXPIRATION_MARGIN < time()) { - return false; - } - - return true; - } - - private function refreshToken(): void - { - if ($this->authenticator === null) { - $this->authenticator = $this->authenticatorFactory->createAuthenticator(); - } - - $this->token = $this->authenticator->getAuthenticationToken($this->resource); - } -} diff --git a/libs/azure-api-client/src/ApiClientFactory/PlainAzureApiClientFactory.php b/libs/azure-api-client/src/ApiClientFactory/PlainAzureApiClientFactory.php deleted file mode 100644 index 6cd99c9bb..000000000 --- a/libs/azure-api-client/src/ApiClientFactory/PlainAzureApiClientFactory.php +++ /dev/null @@ -1,39 +0,0 @@ -, - * middleware?: null|list, - * requestHandler?: null|callable, - * logger?: null|LoggerInterface, - * } - */ -class PlainAzureApiClientFactory -{ - /** - * @param Options $options - */ - public function __construct( - private readonly array $options = [], - ) { - } - - /** - * @param non-empty-string $baseUrl - * @param Options $options - */ - public function createClient(string $baseUrl, array $options = []): ApiClient - { - $options = array_merge($this->options, $options); - $options['baseUrl'] = $baseUrl; - - return new ApiClient($options); - } -} diff --git a/libs/azure-api-client/src/Authentication/AuthenticationToken.php b/libs/azure-api-client/src/Authentication/AuthenticationToken.php new file mode 100644 index 000000000..2769baf49 --- /dev/null +++ b/libs/azure-api-client/src/Authentication/AuthenticationToken.php @@ -0,0 +1,16 @@ +|null $backoffMaxTries + */ public function __construct( - PlainAzureApiClientFactory $clientFactory, - private readonly LoggerInterface $logger, + private readonly string $tenantId, + private readonly string $clientId, + private readonly string $clientSecret, + ?int $backoffMaxTries = null, + ?callable $requestHandler = null, + LoggerInterface $logger = new NullLogger(), ) { - $this->armUrl = (string) getenv(self::ENV_AZURE_AD_RESOURCE); - if (!$this->armUrl) { - $this->armUrl = self::DEFAULT_ARM_URL; - $this->logger->debug( + $armUrl = (string) getenv(self::ENV_AZURE_AD_RESOURCE); + if (!$armUrl) { + $armUrl = self::DEFAULT_ARM_URL; + $logger->debug( self::ENV_AZURE_AD_RESOURCE . ' environment variable is not specified, falling back to default.' ); } @@ -47,22 +49,22 @@ public function __construct( $this->cloudName = (string) getenv(self::ENV_AZURE_ENVIRONMENT); if (!$this->cloudName) { $this->cloudName = self::DEFAULT_PUBLIC_CLOUD_NAME; - $this->logger->debug( + $logger->debug( self::ENV_AZURE_ENVIRONMENT . ' environment variable is not specified, falling back to default.' ); } - $this->tenantId = (string) getenv(self::ENV_AZURE_TENANT_ID); - $this->clientId = (string) getenv(self::ENV_AZURE_CLIENT_ID); - $this->clientSecret = (string) getenv(self::ENV_AZURE_CLIENT_SECRET); - $this->apiClient = $clientFactory->createClient($this->armUrl); + $this->apiClient = new ApiClient( + baseUrl: $armUrl, + backoffMaxTries: $backoffMaxTries, + requestHandler: $requestHandler, + logger: $logger, + ); } - public function getAuthenticationToken(string $resource): TokenResponse + public function getAuthenticationToken(string $resource): AuthenticationToken { - if ($this->authEndpoint === null) { - $this->authEndpoint = $this->getMetadata($this->armUrl, $this->cloudName)->authenticationLoginEndpoint; - } + $this->authEndpoint ??= $this->getMetadata($this->cloudName)->authenticationLoginEndpoint; $request = new Request('POST', sprintf('%s%s/oauth2/token', $this->authEndpoint, $this->tenantId)); $formData = [ @@ -78,28 +80,17 @@ public function getAuthenticationToken(string $resource): TokenResponse ['form_params' => $formData] ); - $this->logger->info('Successfully authenticated using client credentials.'); - return $token; - } - - public function checkUsability(): void - { - $errors = []; - foreach ([self::ENV_AZURE_TENANT_ID, self::ENV_AZURE_CLIENT_ID, self::ENV_AZURE_CLIENT_SECRET] as $envVar) { - if (!getenv($envVar)) { - $errors[] = sprintf('Environment variable "%s" is not set.', $envVar); - } - } - if ($errors) { - throw new ClientException(implode(' ', $errors)); - } + return new AuthenticationToken( + $token->accessToken, + $token->accessTokenExpiration, + ); } - private function getMetadata(string $armUrl, string $cloudName): MetadataResponse + private function getMetadata(string $cloudName): MetadataResponse { try { $metadataList = $this->apiClient->sendRequestAndMapResponse( - new Request('GET', $armUrl), + new Request('GET', ''), MetadataResponse::class, [], true diff --git a/libs/azure-api-client/src/Authentication/Authenticator/ManagedCredentialsAuthenticator.php b/libs/azure-api-client/src/Authentication/Authenticator/ManagedCredentialsAuthenticator.php new file mode 100644 index 000000000..fa83bbba9 --- /dev/null +++ b/libs/azure-api-client/src/Authentication/Authenticator/ManagedCredentialsAuthenticator.php @@ -0,0 +1,62 @@ +|null $backoffMaxTries + */ + public function __construct( + ?int $backoffMaxTries = null, + ?Closure $requestHandler = null, + ?LoggerInterface $logger = null, + ) { + $this->apiClient = new ApiClient( + baseUrl: self::INSTANCE_METADATA_SERVICE_ENDPOINT, + backoffMaxTries: $backoffMaxTries, + requestHandler: $requestHandler, + logger: $logger, + ); + } + + public function getAuthenticationToken(string $resource): AuthenticationToken + { + $token = $this->apiClient->sendRequestAndMapResponse( + new Request( + 'GET', + sprintf( + '/metadata/identity/oauth2/token?%s', + http_build_query([ + 'api-version' => self::API_VERSION, + 'format' => 'text', + 'resource' => $resource, + ]) + ), + [ + 'Metadata' => 'true', + ], + ), + TokenResponse::class + ); + + return new AuthenticationToken( + $token->accessToken, + $token->accessTokenExpiration, + ); + } +} diff --git a/libs/azure-api-client/src/Authentication/Authenticator/StaticTokenAuthenticator.php b/libs/azure-api-client/src/Authentication/Authenticator/StaticTokenAuthenticator.php new file mode 100644 index 000000000..aadaaaf00 --- /dev/null +++ b/libs/azure-api-client/src/Authentication/Authenticator/StaticTokenAuthenticator.php @@ -0,0 +1,20 @@ +value, null); + } +} diff --git a/libs/azure-api-client/src/Authentication/Authenticator/SystemAuthenticatorResolver.php b/libs/azure-api-client/src/Authentication/Authenticator/SystemAuthenticatorResolver.php new file mode 100644 index 000000000..a68c2079d --- /dev/null +++ b/libs/azure-api-client/src/Authentication/Authenticator/SystemAuthenticatorResolver.php @@ -0,0 +1,57 @@ +|null $backoffMaxTries + */ + public function __construct( + private readonly ?int $backoffMaxTries = null, + private readonly ?Closure $requestHandler = null, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function getAuthenticationToken(string $resource): AuthenticationToken + { + $this->resolvedAuthenticator ??= $this->resolveAuthenticator(); + return $this->resolvedAuthenticator->getAuthenticationToken($resource); + } + + private function resolveAuthenticator(): AuthenticatorInterface + { + $tenantId = (string) getenv('AZURE_TENANT_ID'); + $clientId = (string) getenv('AZURE_CLIENT_ID'); + $clientSecret = (string) getenv('AZURE_CLIENT_SECRET'); + if ($tenantId !== '' && $clientId !== '' && $clientSecret !== '') { + $this->logger->debug('Found Azure client credentials in ENV, using ClientCredentialsAuthenticator'); + + return new ClientCredentialsAuthenticator( + $tenantId, + $clientId, + $clientSecret, + backoffMaxTries: $this->backoffMaxTries, + requestHandler: $this->requestHandler, + logger: $this->logger, + ); + } + + $this->logger->debug('Azure client credentials not found in ENV, using ManagedCredentialsAuthenticator'); + return new ManagedCredentialsAuthenticator( + backoffMaxTries: $this->backoffMaxTries, + requestHandler: $this->requestHandler, + logger: $this->logger, + ); + } +} diff --git a/libs/azure-api-client/src/Authentication/AuthenticatorFactory.php b/libs/azure-api-client/src/Authentication/AuthenticatorFactory.php deleted file mode 100644 index b9595aa9f..000000000 --- a/libs/azure-api-client/src/Authentication/AuthenticatorFactory.php +++ /dev/null @@ -1,34 +0,0 @@ -clientFactory, $this->logger); - try { - $authenticator->checkUsability(); - return $authenticator; - } catch (ClientException $e) { - $this->logger->debug( - 'ClientCredentialsEnvironmentAuthenticator is not usable: ' . $e->getMessage() - ); - } - /* ManagedCredentialsAuthenticator checkUsability method has poor performance due to slow responses - from GET /metadata requests */ - return new ManagedCredentialsAuthenticator($this->clientFactory, $this->logger); - } -} diff --git a/libs/azure-api-client/src/Authentication/AuthenticatorInterface.php b/libs/azure-api-client/src/Authentication/AuthenticatorInterface.php deleted file mode 100644 index 5d6429000..000000000 --- a/libs/azure-api-client/src/Authentication/AuthenticatorInterface.php +++ /dev/null @@ -1,12 +0,0 @@ -token === null || !$this->isTokenValid($this->token)) { + $this->token = $this->authenticator->getAuthenticationToken($this->resource); + } + + return $request->withHeader('Authorization', 'Bearer ' . $this->token->value); + } + + private function isTokenValid(AuthenticationToken $token): bool + { + if ($token->expiresAt === null) { + return true; + } + + $expirationTimestamp = $token->expiresAt->getTimestamp(); + if ($expirationTimestamp - self::EXPIRATION_MARGIN < time()) { + return false; + } + + return true; + } +} diff --git a/libs/azure-api-client/src/Authentication/ManagedCredentialsAuthenticator.php b/libs/azure-api-client/src/Authentication/ManagedCredentialsAuthenticator.php deleted file mode 100644 index 14d78751e..000000000 --- a/libs/azure-api-client/src/Authentication/ManagedCredentialsAuthenticator.php +++ /dev/null @@ -1,71 +0,0 @@ -clientFactory->createClient(self::INSTANCE_METADATA_SERVICE_ENDPOINT); - $token = $client->sendRequestAndMapResponse( - new Request( - 'GET', - sprintf( - '/metadata/identity/oauth2/token?%s', - http_build_query([ - 'api-version' => self::API_VERSION, - 'format' => 'text', - 'resource' => $resource, - ]) - ), - [ - 'Metadata' => 'true', - ], - ), - TokenResponse::class - ); - - $this->logger->info('Successfully authenticated using instance metadata.'); - return $token; - } - - public function checkUsability(): void - { - try { - $client = $this->clientFactory->createClient( - self::INSTANCE_METADATA_SERVICE_ENDPOINT, - ['backoffMaxTries' => 1] - ); - $client->sendRequest( - new Request( - 'GET', - sprintf('/metadata?%s', http_build_query([ - 'api-version' => self::API_VERSION, - 'format' => 'text', - ])), - [ - 'Metadata' => 'true', - ], - ), - ); - } catch (ClientException $e) { - throw new ClientException('Instance metadata service not available: ' . $e->getMessage(), 0, $e); - } - } -} diff --git a/libs/azure-api-client/src/Authentication/MetadataResponse.php b/libs/azure-api-client/src/Authentication/Model/MetadataResponse.php similarity index 91% rename from libs/azure-api-client/src/Authentication/MetadataResponse.php rename to libs/azure-api-client/src/Authentication/Model/MetadataResponse.php index a9375e502..92c057325 100644 --- a/libs/azure-api-client/src/Authentication/MetadataResponse.php +++ b/libs/azure-api-client/src/Authentication/Model/MetadataResponse.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Keboola\AzureApiClient\Authentication; +namespace Keboola\AzureApiClient\Authentication\Model; use Keboola\AzureApiClient\ResponseModelInterface; diff --git a/libs/azure-api-client/src/Authentication/TokenResponse.php b/libs/azure-api-client/src/Authentication/Model/TokenResponse.php similarity index 95% rename from libs/azure-api-client/src/Authentication/TokenResponse.php rename to libs/azure-api-client/src/Authentication/Model/TokenResponse.php index e0fcfc180..86f9c7eca 100644 --- a/libs/azure-api-client/src/Authentication/TokenResponse.php +++ b/libs/azure-api-client/src/Authentication/Model/TokenResponse.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Keboola\AzureApiClient\Authentication; +namespace Keboola\AzureApiClient\Authentication\Model; use DateTimeImmutable; use Keboola\AzureApiClient\Exception\InvalidResponseException; diff --git a/libs/azure-api-client/src/Marketplace/MarketplaceApiClient.php b/libs/azure-api-client/src/Marketplace/MarketplaceApiClient.php index e2e3d0b45..4b639e0b6 100644 --- a/libs/azure-api-client/src/Marketplace/MarketplaceApiClient.php +++ b/libs/azure-api-client/src/Marketplace/MarketplaceApiClient.php @@ -6,26 +6,34 @@ use GuzzleHttp\Psr7\Request; use Keboola\AzureApiClient\ApiClient; -use Keboola\AzureApiClient\ApiClientFactory\AuthenticatedAzureApiClientFactory; +use Keboola\AzureApiClient\Authentication\Authenticator\AuthenticatorInterface; use Keboola\AzureApiClient\Json; use Keboola\AzureApiClient\Marketplace\Model\ActivateSubscriptionRequest; use Keboola\AzureApiClient\Marketplace\Model\ResolveSubscriptionResult; use Keboola\AzureApiClient\Marketplace\Model\Subscription; +use Psr\Log\LoggerInterface; class MarketplaceApiClient { + private ApiClient $apiClient; + + /** + * @param int<0, max>|null $backoffMaxTries + */ public function __construct( - private readonly ApiClient $apiClient, + ?int $backoffMaxTries = null, + ?AuthenticatorInterface $authenticator = null, + ?callable $requestHandler = null, + ?LoggerInterface $logger = null, ) { - } - - public static function create(AuthenticatedAzureApiClientFactory $clientFactory): self - { - $apiClient = $clientFactory->createClient( - 'https://marketplaceapi.microsoft.com', - Resources::AZURE_MARKETPLACE + $this->apiClient = new ApiClient( + baseUrl: 'https://marketplaceapi.microsoft.com', + backoffMaxTries: $backoffMaxTries, + authenticator: $authenticator, + requestHandler: $requestHandler, + logger: $logger, ); - return new self($apiClient); + $this->apiClient->authenticate(Resources::AZURE_MARKETPLACE); } public function resolveSubscription(string $marketplaceToken): ResolveSubscriptionResult diff --git a/libs/azure-api-client/src/Marketplace/MeteringServiceApiClient.php b/libs/azure-api-client/src/Marketplace/MeteringServiceApiClient.php index 45398ff60..55cb779e9 100644 --- a/libs/azure-api-client/src/Marketplace/MeteringServiceApiClient.php +++ b/libs/azure-api-client/src/Marketplace/MeteringServiceApiClient.php @@ -6,26 +6,34 @@ use GuzzleHttp\Psr7\Request; use Keboola\AzureApiClient\ApiClient; -use Keboola\AzureApiClient\ApiClientFactory\AuthenticatedAzureApiClientFactory; +use Keboola\AzureApiClient\Authentication\Authenticator\AuthenticatorInterface; use Keboola\AzureApiClient\Json; use Keboola\AzureApiClient\Marketplace\Model\ReportUsageEventsBatchResult; use Keboola\AzureApiClient\Marketplace\Model\UsageEvent; use Keboola\AzureApiClient\Marketplace\Model\UsageEventResult; +use Psr\Log\LoggerInterface; class MeteringServiceApiClient { + private ApiClient $apiClient; + + /** + * @param int<0, max>|null $backoffMaxTries + */ public function __construct( - private readonly ApiClient $apiClient, + ?int $backoffMaxTries = null, + ?AuthenticatorInterface $authenticator = null, + ?callable $requestHandler = null, + ?LoggerInterface $logger = null, ) { - } - - public static function create(AuthenticatedAzureApiClientFactory $clientFactory): self - { - $apiClient = $clientFactory->createClient( - 'https://marketplaceapi.microsoft.com/api/', - Resources::AZURE_MARKETPLACE + $this->apiClient = new ApiClient( + baseUrl: 'https://marketplaceapi.microsoft.com/api/', + backoffMaxTries: $backoffMaxTries, + authenticator: $authenticator, + requestHandler: $requestHandler, + logger: $logger, ); - return new self($apiClient); + $this->apiClient->authenticate(Resources::AZURE_MARKETPLACE); } /** diff --git a/libs/azure-api-client/tests/ApiClientFactory/AuthenticatedAzureApiClientFactoryTest.php b/libs/azure-api-client/tests/ApiClientFactory/AuthenticatedAzureApiClientFactoryTest.php deleted file mode 100644 index 855621231..000000000 --- a/libs/azure-api-client/tests/ApiClientFactory/AuthenticatedAzureApiClientFactoryTest.php +++ /dev/null @@ -1,88 +0,0 @@ -createRequestHandler($requestsHistory, [ - new Response( - 200, - ['Content-Type' => 'application/json'], - Json::encodeArray(['foo' => 'bar']), - ), - new Response( - 200, - ['Content-Type' => 'application/json'], - Json::encodeArray(['foo' => 'bar']), - ), - ]); - - $factory = new AuthenticatedAzureApiClientFactory( - $this->createFakeAuthenticatorFactory('auth-token'), - [ - 'requestHandler' => $requestHandler, - ], - ); - $client = $factory->createClient('http://example.com', 'foo'); - - $client->sendRequest(new Request('GET', '/foo')); - self::assertCount(1, $requestsHistory); - - $request = $requestsHistory[0]['request']; - self::assertSame('Bearer auth-token', $request->getHeaderLine('Authorization')); - } - - /** - * @param non-empty-string $authToken - */ - private function createFakeAuthenticatorFactory(string $authToken): AuthenticatorFactory - { - $authenticator = $this->createMock(AuthenticatorInterface::class); - $authenticator->expects(self::once()) - ->method('getAuthenticationToken') - ->willReturn(new TokenResponse( - $authToken, - new DateTimeImmutable('+1 hour'), - )) - ; - - $authenticatorFactory = $this->createMock(AuthenticatorFactory::class); - $authenticatorFactory->expects(self::once()) - ->method('createAuthenticator') - ->willReturn($authenticator) - ; - - return $authenticatorFactory; - } - - /** - * @param list $requestsHistory - * @param list $responses - */ - private function createRequestHandler(?array &$requestsHistory, array $responses): HandlerStack - { - $requestsHistory = []; - - $stack = HandlerStack::create(new MockHandler($responses)); - $stack->push(Middleware::history($requestsHistory)); - - return $stack; - } -} diff --git a/libs/azure-api-client/tests/ApiClientFunctionalTest.php b/libs/azure-api-client/tests/ApiClientFunctionalTest.php index a863c552d..dafa016a5 100644 --- a/libs/azure-api-client/tests/ApiClientFunctionalTest.php +++ b/libs/azure-api-client/tests/ApiClientFunctionalTest.php @@ -26,9 +26,9 @@ public function testSendRequest(): void ], ]); - $apiClient = new ApiClient([ - 'baseUrl' => $mockserver->getServerUrl(), - ]); + $apiClient = new ApiClient( + baseUrl: $mockserver->getServerUrl(), + ); $apiClient->sendRequest(new Request('GET', 'foo/bar')); $recordedRequests = $mockserver->fetchRecordedRequests([ @@ -64,9 +64,9 @@ public function testSendRequestWithMappedResponse(): void ], ]); - $apiClient = new ApiClient([ - 'baseUrl' => $mockserver->getServerUrl(), - ]); + $apiClient = new ApiClient( + baseUrl: $mockserver->getServerUrl(), + ); $response = $apiClient->sendRequestAndMapResponse( new Request( @@ -120,9 +120,9 @@ public function testSendRequestWithMappedArrayResponse(): void ], ]); - $apiClient = new ApiClient([ - 'baseUrl' => $mockserver->getServerUrl(), - ]); + $apiClient = new ApiClient( + baseUrl: $mockserver->getServerUrl(), + ); $response = $apiClient->sendRequestAndMapResponse( new Request( @@ -162,9 +162,9 @@ public function testSendRequestFailingWithRegularError(): void ], ]); - $apiClient = new ApiClient([ - 'baseUrl' => $mockserver->getServerUrl(), - ]); + $apiClient = new ApiClient( + baseUrl: $mockserver->getServerUrl(), + ); $this->expectException(ClientException::class); $this->expectExceptionMessage('BadRequest: This is not good'); @@ -187,9 +187,9 @@ public function testSendRequestFailingWithUnexpectedError(): void ], ]); - $apiClient = new ApiClient([ - 'baseUrl' => $mockserver->getServerUrl(), - ]); + $apiClient = new ApiClient( + baseUrl: $mockserver->getServerUrl(), + ); $this->expectException(ClientException::class); $this->expectExceptionMessage( @@ -214,9 +214,9 @@ public function testSendRequestFailingOnResponseMapping(): void ], ]); - $apiClient = new ApiClient([ - 'baseUrl' => $mockserver->getServerUrl(), - ]); + $apiClient = new ApiClient( + baseUrl: $mockserver->getServerUrl(), + ); $this->expectException(ClientException::class); $this->expectExceptionMessage( @@ -254,9 +254,9 @@ public function testRetrySuccess(): void ], ]); - $apiClient = new ApiClient([ - 'baseUrl' => $mockserver->getServerUrl(), - ]); + $apiClient = new ApiClient( + baseUrl: $mockserver->getServerUrl(), + ); $apiClient->sendRequest(new Request('GET', 'foo/bar')); $recordedRequests = $mockserver->fetchRecordedRequests([ @@ -281,10 +281,10 @@ public function testRetryFailure(): void ], ]); - $apiClient = new ApiClient([ - 'baseUrl' => $mockserver->getServerUrl(), - 'backoffMaxTries' => 2, - ]); + $apiClient = new ApiClient( + baseUrl: $mockserver->getServerUrl(), + backoffMaxTries: 2, + ); $this->expectException(ClientException::class); $this->expectExceptionMessage( @@ -323,10 +323,10 @@ public function testRetryOnThrottling(): void ], ]); - $apiClient = new ApiClient([ - 'baseUrl' => $mockserver->getServerUrl(), - 'backoffMaxTries' => 2, - ]); + $apiClient = new ApiClient( + baseUrl: $mockserver->getServerUrl(), + backoffMaxTries: 2, + ); $response = $apiClient->sendRequestAndMapResponse( new Request('GET', 'foo/bar'), diff --git a/libs/azure-api-client/tests/ApiClientTest.php b/libs/azure-api-client/tests/ApiClientTest.php index f22aade18..8ff2d0091 100644 --- a/libs/azure-api-client/tests/ApiClientTest.php +++ b/libs/azure-api-client/tests/ApiClientTest.php @@ -5,14 +5,11 @@ namespace Keboola\AzureApiClient\Tests; use GuzzleHttp\Client as GuzzleClient; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Middleware; use GuzzleHttp\Promise\Create; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; +use InvalidArgumentException; use Keboola\AzureApiClient\ApiClient; -use Keboola\AzureApiClient\Exception\ClientException; use Monolog\Handler\TestHandler; use Monolog\Logger; use PHPUnit\Framework\TestCase; @@ -49,82 +46,42 @@ public function testCreateClientWithDefaults(): void } /** @dataProvider provideInvalidOptions */ - public function testInvalidOptions(array $options, string $expectedError): void - { - $this->expectException(ClientException::class); + public function testInvalidOptions( + ?string $baseUrl, + ?int $backoffMaxTries, + string $expectedError + ): void { + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage($expectedError); - new ApiClient($options); + new ApiClient( + baseUrl: $baseUrl, // @phpstan-ignore-line invalid arguments provided intentionally + backoffMaxTries: $backoffMaxTries, // @phpstan-ignore-line + ); } public function provideInvalidOptions(): iterable { - // phpcs:disable Generic.Files.LineLength yield 'empty baseUrl' => [ - 'options' => [ - 'baseUrl' => '', - ], - 'error' => 'Invalid options when creating client: [baseUrl]: This value is too short. It should have 1 character or more.', - ]; - - yield 'invalid baseUrl' => [ - 'options' => [ - 'baseUrl' => 'foo', - ], - 'error' => 'Invalid options when creating client: [baseUrl]: This value is not a valid URL.', - ]; - - yield 'invalid backoffMaxTries' => [ - 'options' => [ - 'backoffMaxTries' => 'foo', - ], - 'error' => 'Invalid options when creating client: [backoffMaxTries]: This value should be of type int.', + 'baseUrl' => '', + 'backoffMaxTries' => null, + 'error' => 'Expected a value to contain at least 1 characters. Got: ""', ]; yield 'negative backoffMaxTries' => [ - 'options' => [ - 'backoffMaxTries' => -1, - ], - 'error' => 'Invalid options when creating client: [backoffMaxTries]: This value should be greater than or equal to 0.', - ]; - - yield 'invalid middleware' => [ - 'options' => [ - 'middleware' => 'foo', - ], - 'error' => 'Invalid options when creating client: [middleware]: This value should be of type iterable.', - ]; - - yield 'invalid middleware item' => [ - 'options' => [ - 'middleware' => ['foo'], - ], - 'error' => 'Invalid options when creating client: [middleware][0]: This value should be of type callable.', - ]; - - yield 'invalid requestHandler' => [ - 'options' => [ - 'requestHandler' => ['foo'], - ], - 'error' => 'Invalid options when creating client: [requestHandler]: This value should be of type callable.', - ]; - - yield 'invalid logger' => [ - 'options' => [ - 'logger' => 'foo', - ], - 'error' => 'Invalid options when creating client: [logger]: This value should be of type Psr\Log\LoggerInterface.', + 'baseUrl' => null, + 'backoffMaxTries' => -1, + 'error' => 'Expected a value greater than or equal to 0. Got: -1', ]; - // phpcs:enable Generic.Files.LineLength } public function testLogger(): void { - $client = new ApiClient([ - 'logger' => $this->logger, - 'requestHandler' => fn($request) => Create::promiseFor(new Response(201, [], 'boo')), - ]); + $client = new ApiClient( + logger: $this->logger, + requestHandler: fn($request) => Create::promiseFor(new Response(201, [], 'boo')), + ); $client->sendRequest(new Request('GET', '/')); self::assertTrue($this->logsHandler->hasInfoThatMatches( '#^[\w\d]+ Keboola Azure PHP Client - \[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00\] "GET /1.1" 201 $#', diff --git a/libs/azure-api-client/tests/Authentication/AuthenticationFactoryTest.php b/libs/azure-api-client/tests/Authentication/AuthenticationFactoryTest.php deleted file mode 100644 index 0f1e74ad4..000000000 --- a/libs/azure-api-client/tests/Authentication/AuthenticationFactoryTest.php +++ /dev/null @@ -1,123 +0,0 @@ -logsHandler = new TestHandler(); - $this->logger = new Logger('test', [$this->logsHandler]); - } - - public function testCreateEnvironmentAuthenticator(): void - { - $requestHandler = $this->createRequestHandler($requestsHistory, []); - $authenticationFactory = new AuthenticatorFactory( - new PlainAzureApiClientFactory([ - 'requestHandler' => $requestHandler, - ]), - $this->logger - ); - - putenv('AZURE_TENANT_ID=foo'); - putenv('AZURE_CLIENT_ID=foo'); - putenv('AZURE_CLIENT_SECRET=foo'); - - $authenticator = $authenticationFactory->createAuthenticator(); - self::assertInstanceOf(ClientCredentialsEnvironmentAuthenticator::class, $authenticator); - } - - public function testCreateManagedCredentialsAuthenticator(): void - { - $requestHandler = $this->createRequestHandler($requestsHistory, []); - $authenticationFactory = new AuthenticatorFactory( - new PlainAzureApiClientFactory([ - 'requestHandler' => $requestHandler, - ]), - $this->logger - ); - - putenv('AZURE_TENANT_ID='); - putenv('AZURE_CLIENT_ID=foo'); - putenv('AZURE_CLIENT_SECRET=foo'); - - $authenticator = $authenticationFactory->createAuthenticator(); - self::assertInstanceOf(ManagedCredentialsAuthenticator::class, $authenticator); - - self::assertTrue($this->logsHandler->hasDebug( - 'ClientCredentialsEnvironmentAuthenticator is not usable: ' . - 'Environment variable "AZURE_TENANT_ID" is not set.' - )); - } - - public function testManagedCredentialsAuthenticatorIsUsedEventIfNotUsable(): void - { - /* Even if the instance metadata is not available, the managed credentials authenticator is - returned because it's verification is optimized out */ - $requestHandler = $this->createRequestHandler($requestsHistory, [ - new Response(400), - ]); - $authenticationFactory = new AuthenticatorFactory( - new PlainAzureApiClientFactory([ - 'requestHandler' => $requestHandler, - ]), - $this->logger - ); - - putenv('AZURE_TENANT_ID='); - putenv('AZURE_CLIENT_ID=foo'); - putenv('AZURE_CLIENT_SECRET=foo'); - - $authenticator = $authenticationFactory->createAuthenticator(); - self::assertInstanceOf(ManagedCredentialsAuthenticator::class, $authenticator); - self::assertInstanceOf(ManagedCredentialsAuthenticator::class, $authenticator); - self::assertTrue($this->logsHandler->hasDebugThatContains( - 'ClientCredentialsEnvironmentAuthenticator is not usable: ' . - 'Environment variable "AZURE_TENANT_ID" is not set.' - )); - - $this->expectException(ClientException::class); - $this->expectExceptionMessage( - 'Client error: `GET http://169.254.169.254/metadata/identity/oauth2/token?api-version=2019-11-01&' . - 'format=text&resource=foo` resulted in a `400 Bad Request` response' - ); - $authenticator->getAuthenticationToken('foo'); - } - - /** - * @param list $requestsHistory - * @param list $responses - */ - private function createRequestHandler(?array &$requestsHistory, array $responses): HandlerStack - { - $requestsHistory = []; - - $stack = HandlerStack::create(new MockHandler($responses)); - $stack->push(Middleware::history($requestsHistory)); - - return $stack; - } -} diff --git a/libs/azure-api-client/tests/Authentication/ClientCredentialsEnvironmentAuthenticatorTest.php b/libs/azure-api-client/tests/Authentication/Authenticator/ClientCredentialsAuthenticatorTest.php similarity index 59% rename from libs/azure-api-client/tests/Authentication/ClientCredentialsEnvironmentAuthenticatorTest.php rename to libs/azure-api-client/tests/Authentication/Authenticator/ClientCredentialsAuthenticatorTest.php index 496e196b1..6fd7041d1 100644 --- a/libs/azure-api-client/tests/Authentication/ClientCredentialsEnvironmentAuthenticatorTest.php +++ b/libs/azure-api-client/tests/Authentication/Authenticator/ClientCredentialsAuthenticatorTest.php @@ -2,25 +2,23 @@ declare(strict_types=1); -namespace Keboola\AzureApiClient\Tests\Authentication; +namespace Keboola\AzureApiClient\Tests\Authentication\Authenticator; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Middleware; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; -use Keboola\AzureApiClient\ApiClientFactory\PlainAzureApiClientFactory; -use Keboola\AzureApiClient\Authentication\ClientCredentialsEnvironmentAuthenticator; +use Keboola\AzureApiClient\Authentication\Authenticator\ClientCredentialsAuthenticator; use Keboola\AzureApiClient\Exception\ClientException; use Keboola\AzureApiClient\Json; -use Keboola\AzureApiClient\Tests\BaseTest; use Monolog\Handler\TestHandler; use Monolog\Logger; +use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; -class ClientCredentialsEnvironmentAuthenticatorTest extends BaseTest +class ClientCredentialsAuthenticatorTest extends TestCase { - private readonly PlainAzureApiClientFactory $clientFactory; private readonly LoggerInterface $logger; private readonly TestHandler $logsHandler; @@ -31,19 +29,33 @@ public function setUp(): void $this->logsHandler = new TestHandler(); $this->logger = new Logger('tests', [$this->logsHandler]); - $this->clientFactory = new PlainAzureApiClientFactory(); + putenv('AZURE_TENANT_ID'); + putenv('AZURE_CLIENT_ID'); + putenv('AZURE_CLIENT_SECRET'); + putenv('AZURE_AD_RESOURCE'); + putenv('AZURE_ENVIRONMENT'); } public function testOptionalEnvsFallback(): void { - putenv('AZURE_AD_RESOURCE=http://foo'); + putenv('AZURE_AD_RESOURCE=https://foo'); putenv('AZURE_ENVIRONMENT=foo'); - new ClientCredentialsEnvironmentAuthenticator($this->clientFactory, $this->logger); + new ClientCredentialsAuthenticator( + 'tenant-id', + 'client-id', + 'client-secret', + logger: $this->logger, + ); self::assertCount(0, $this->logsHandler->getRecords()); putenv('AZURE_AD_RESOURCE'); putenv('AZURE_ENVIRONMENT'); - new ClientCredentialsEnvironmentAuthenticator($this->clientFactory, $this->logger); + new ClientCredentialsAuthenticator( + 'tenant-id', + 'client-id', + 'client-secret', + logger: $this->logger, + ); self::assertTrue($this->logsHandler->hasDebug( 'AZURE_AD_RESOURCE environment variable is not specified, falling back to default.' )); @@ -52,43 +64,11 @@ public function testOptionalEnvsFallback(): void )); } - public function testCheckUsabilitySuccess(): void - { - putenv('AZURE_TENANT_ID=foo'); - putenv('AZURE_CLIENT_ID=foo'); - putenv('AZURE_CLIENT_SECRET=foo'); - - $authenticator = new ClientCredentialsEnvironmentAuthenticator($this->clientFactory, $this->logger); - $authenticator->checkUsability(); - - self::expectNotToPerformAssertions(); - } - - /** @dataProvider provideCheckUsabilityFailureTestData */ - public function testCheckUsabilityFailure(string $requiredEnv): void - { - putenv($requiredEnv); - - $authenticator = new ClientCredentialsEnvironmentAuthenticator($this->clientFactory, $this->logger); - - $this->expectException(ClientException::class); - $this->expectExceptionMessage(sprintf('Environment variable "%s" is not set.', $requiredEnv)); - - $authenticator->checkUsability(); - } - - public function provideCheckUsabilityFailureTestData(): iterable - { - yield 'AZURE_TENANT_ID' => ['AZURE_TENANT_ID']; - yield 'AZURE_CLIENT_ID' => ['AZURE_CLIENT_ID']; - yield 'AZURE_CLIENT_SECRET' => ['AZURE_CLIENT_SECRET']; - } - public function testGetAuthenticationToken(): void { $metadata = $this->getSampleArmMetadata(); - $requestHandler = self::prepareGuzzleMockHandler($requestsHistory, [ + $requestHandler = self::createRequestHandler($requestsHistory, [ new Response( 200, ['Content-Type' => 'application/json'], @@ -106,19 +86,18 @@ public function testGetAuthenticationToken(): void ), ]); - $apiClientFactory = new PlainAzureApiClientFactory([ - 'requestHandler' => $requestHandler, - ]); - - $auth = new ClientCredentialsEnvironmentAuthenticator( - $apiClientFactory, - $this->logger + $auth = new ClientCredentialsAuthenticator( + 'tenant-id', + 'client-id', + 'client-secret', + requestHandler: $requestHandler, + logger: $this->logger, ); $token = $auth->getAuthenticationToken('resource-id'); self::assertCount(2, $requestsHistory); - self::assertSame('ey....ey', $token->accessToken); - self::assertEqualsWithDelta(time() + 3599, $token->accessTokenExpiration->getTimestamp(), 1); + self::assertSame('ey....ey', $token->value); + self::assertEqualsWithDelta(time() + 3599, $token->expiresAt?->getTimestamp(), 1); $request = $requestsHistory[0]['request']; self::assertSame( @@ -128,11 +107,11 @@ public function testGetAuthenticationToken(): void self::assertSame('GET', $request->getMethod()); $request = $requestsHistory[1]['request']; - self::assertSame('https://login.windows.net/tenant123/oauth2/token', $request->getUri()->__toString()); + self::assertSame('https://login.windows.net/tenant-id/oauth2/token', $request->getUri()->__toString()); self::assertSame('POST', $request->getMethod()); self::assertSame('application/x-www-form-urlencoded', $request->getHeader('Content-type')[0]); self::assertSame( - 'grant_type=client_credentials&client_id=client123&client_secret=secret123&resource=resource-id', + 'grant_type=client_credentials&client_id=client-id&client_secret=client-secret&resource=resource-id', $request->getBody()->getContents() ); } @@ -146,7 +125,7 @@ public function testGetAuthenticationTokenWithCustomMetadata(): void putenv('AZURE_ENVIRONMENT=my-azure'); putenv('AZURE_AD_RESOURCE=https://example.com'); - $requestHandler = self::prepareGuzzleMockHandler($requestsHistory, [ + $requestHandler = self::createRequestHandler($requestsHistory, [ new Response( 200, ['Content-Type' => 'application/json'], @@ -164,19 +143,18 @@ public function testGetAuthenticationTokenWithCustomMetadata(): void ), ]); - $apiClientFactory = new PlainAzureApiClientFactory([ - 'requestHandler' => $requestHandler, - ]); - - $auth = new ClientCredentialsEnvironmentAuthenticator( - $apiClientFactory, - $this->logger + $auth = new ClientCredentialsAuthenticator( + 'tenant-id', + 'client-id', + 'client-secret', + requestHandler: $requestHandler, + logger: $this->logger, ); $token = $auth->getAuthenticationToken('resource-id'); self::assertCount(2, $requestsHistory); - self::assertSame('ey....ey', $token->accessToken); - self::assertEqualsWithDelta(time() + 3599, $token->accessTokenExpiration->getTimestamp(), 1); + self::assertSame('ey....ey', $token->value); + self::assertEqualsWithDelta(time() + 3599, $token->expiresAt?->getTimestamp(), 1); $request = $requestsHistory[0]['request']; self::assertSame( @@ -186,11 +164,11 @@ public function testGetAuthenticationTokenWithCustomMetadata(): void self::assertSame('GET', $request->getMethod()); $request = $requestsHistory[1]['request']; - self::assertSame('https://my-custom-login/tenant123/oauth2/token', $request->getUri()->__toString()); + self::assertSame('https://my-custom-login/tenant-id/oauth2/token', $request->getUri()->__toString()); self::assertSame('POST', $request->getMethod()); self::assertSame('application/x-www-form-urlencoded', $request->getHeader('Content-type')[0]); self::assertSame( - 'grant_type=client_credentials&client_id=client123&client_secret=secret123&resource=resource-id', + 'grant_type=client_credentials&client_id=client-id&client_secret=client-secret&resource=resource-id', $request->getBody()->getContents() ); } @@ -199,7 +177,7 @@ public function testGetAuthenticationTokenWithInvalidCustomMetadata(): void { putenv('AZURE_ENVIRONMENT=non-existent'); - $requestHandler = self::prepareGuzzleMockHandler($requestsHistory, [ + $requestHandler = self::createRequestHandler($requestsHistory, [ new Response( 200, ['Content-Type' => 'application/json'], @@ -207,13 +185,12 @@ public function testGetAuthenticationTokenWithInvalidCustomMetadata(): void ), ]); - $apiClientFactory = new PlainAzureApiClientFactory([ - 'requestHandler' => $requestHandler, - ]); - - $auth = new ClientCredentialsEnvironmentAuthenticator( - $apiClientFactory, - $this->logger + $auth = new ClientCredentialsAuthenticator( + 'tenant-id', + 'client-id', + 'client-secret', + requestHandler: $requestHandler, + logger: $this->logger, ); $this->expectException(ClientException::class); @@ -224,7 +201,7 @@ public function testGetAuthenticationTokenWithInvalidCustomMetadata(): void public function testGetAuthenticationTokenMetadataRetry(): void { - $requestHandler = self::prepareGuzzleMockHandler($requestsHistory, [ + $requestHandler = self::createRequestHandler($requestsHistory, [ new Response( 500, ['Content-Type' => 'application/json'], @@ -247,23 +224,22 @@ public function testGetAuthenticationTokenMetadataRetry(): void ), ]); - $apiClientFactory = new PlainAzureApiClientFactory([ - 'requestHandler' => $requestHandler, - ]); - - $auth = new ClientCredentialsEnvironmentAuthenticator( - $apiClientFactory, - $this->logger + $auth = new ClientCredentialsAuthenticator( + 'tenant-id', + 'client-id', + 'client-secret', + requestHandler: $requestHandler, + logger: $this->logger, ); $token = $auth->getAuthenticationToken('resource-id'); - self::assertEquals('ey....ey', $token->accessToken); - self::assertEqualsWithDelta(time() + 3599, $token->accessTokenExpiration->getTimestamp(), 1); + self::assertEquals('ey....ey', $token->value); + self::assertEqualsWithDelta(time() + 3599, $token->expiresAt?->getTimestamp(), 1); } public function testGetAuthenticationTokenMetadataFailure(): void { - $requestHandler = self::prepareGuzzleMockHandler($requestsHistory, [ + $requestHandler = self::createRequestHandler($requestsHistory, [ new Response( 200, ['Content-Type' => 'application/json'], @@ -271,13 +247,12 @@ public function testGetAuthenticationTokenMetadataFailure(): void ), ]); - $apiClientFactory = new PlainAzureApiClientFactory([ - 'requestHandler' => $requestHandler, - ]); - - $auth = new ClientCredentialsEnvironmentAuthenticator( - $apiClientFactory, - $this->logger + $auth = new ClientCredentialsAuthenticator( + 'tenant-id', + 'client-id', + 'client-secret', + requestHandler: $requestHandler, + logger: $this->logger, ); $this->expectException(ClientException::class); @@ -287,7 +262,7 @@ public function testGetAuthenticationTokenMetadataFailure(): void public function testGetAuthenticationTokenTokenError(): void { - $requestHandler = self::prepareGuzzleMockHandler($requestsHistory, [ + $requestHandler = self::createRequestHandler($requestsHistory, [ new Response( 200, ['Content-Type' => 'application/json'], @@ -300,13 +275,12 @@ public function testGetAuthenticationTokenTokenError(): void ), ]); - $apiClientFactory = new PlainAzureApiClientFactory([ - 'requestHandler' => $requestHandler, - ]); - - $auth = new ClientCredentialsEnvironmentAuthenticator( - $apiClientFactory, - $this->logger + $auth = new ClientCredentialsAuthenticator( + 'tenant-id', + 'client-id', + 'client-secret', + requestHandler: $requestHandler, + logger: $this->logger, ); $this->expectException(ClientException::class); @@ -318,10 +292,10 @@ public function testGetAuthenticationTokenTokenError(): void /** * @param list $requestsHistory - * @param list $responses + * @param array $responses * @return HandlerStack */ - private static function prepareGuzzleMockHandler(?array &$requestsHistory, array $responses): HandlerStack + private static function createRequestHandler(?array &$requestsHistory, array $responses): HandlerStack { $requestsHistory = []; @@ -330,4 +304,9 @@ private static function prepareGuzzleMockHandler(?array &$requestsHistory, array return $stack; } + + private function getSampleArmMetadata(): array + { + return Json::decodeArray((string) file_get_contents(__DIR__.'/arm-metadata.json')); + } } diff --git a/libs/azure-api-client/tests/Authentication/ManagedCredentialsAuthenticatorTest.php b/libs/azure-api-client/tests/Authentication/Authenticator/ManagedCredentialsAuthenticatorTest.php similarity index 50% rename from libs/azure-api-client/tests/Authentication/ManagedCredentialsAuthenticatorTest.php rename to libs/azure-api-client/tests/Authentication/Authenticator/ManagedCredentialsAuthenticatorTest.php index 4dbd39e6a..d2a176213 100644 --- a/libs/azure-api-client/tests/Authentication/ManagedCredentialsAuthenticatorTest.php +++ b/libs/azure-api-client/tests/Authentication/Authenticator/ManagedCredentialsAuthenticatorTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Keboola\AzureApiClient\Tests\Authentication; +namespace Keboola\AzureApiClient\Tests\Authentication\Authenticator; +use Closure; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Middleware; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; -use Keboola\AzureApiClient\ApiClientFactory\PlainAzureApiClientFactory; -use Keboola\AzureApiClient\Authentication\ManagedCredentialsAuthenticator; +use Keboola\AzureApiClient\Authentication\Authenticator\ManagedCredentialsAuthenticator; use Keboola\AzureApiClient\Exception\ClientException; use Monolog\Handler\TestHandler; use Monolog\Logger; @@ -45,15 +45,14 @@ public function testGetAuthenticationToken(): void ), ]); - $apiClientFactory = new PlainAzureApiClientFactory([ - 'requestHandler' => $requestHandler, - ]); - - $auth = new ManagedCredentialsAuthenticator($apiClientFactory, $this->logger); + $auth = new ManagedCredentialsAuthenticator( + requestHandler: $requestHandler(...), + logger: $this->logger, + ); $token = $auth->getAuthenticationToken('resource-id'); - self::assertSame('ey....ey', $token->accessToken); - self::assertEqualsWithDelta(time() + 3599, $token->accessTokenExpiration->getTimestamp(), 1); + self::assertSame('ey....ey', $token->value); + self::assertEqualsWithDelta(time() + 3599, $token->expiresAt?->getTimestamp(), 1); self::assertCount(1, $requestsHistory); $request = $requestsHistory[0]['request']; @@ -64,8 +63,6 @@ public function testGetAuthenticationToken(): void ); self::assertSame('GET', $request->getMethod()); self::assertSame('true', $request->getHeader('Metadata')[0]); - - self::assertTrue($this->logsHandler->hasInfo('Successfully authenticated using instance metadata.')); } public function testGetAuthenticationTokenWithInvalidResponse(): void @@ -80,11 +77,10 @@ public function testGetAuthenticationTokenWithInvalidResponse(): void ), ]); - $apiClientFactory = new PlainAzureApiClientFactory([ - 'requestHandler' => $requestHandler, - ]); - - $auth = new ManagedCredentialsAuthenticator($apiClientFactory, $this->logger); + $auth = new ManagedCredentialsAuthenticator( + requestHandler: $requestHandler(...), + logger: $this->logger, + ); $this->expectException(ClientException::class); $this->expectExceptionMessage( @@ -93,64 +89,6 @@ public function testGetAuthenticationTokenWithInvalidResponse(): void $auth->getAuthenticationToken('resource-id'); } - public function testCheckUsabilitySuccess(): void - { - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 200, - ['Content-Type' => 'application/json'], - '' - ), - ]); - - $apiClientFactory = new PlainAzureApiClientFactory([ - 'requestHandler' => $requestHandler, - ]); - - $auth = new ManagedCredentialsAuthenticator($apiClientFactory, $this->logger); - - $auth->checkUsability(); - self::assertCount(1, $requestsHistory); - - $request = $requestsHistory[0]['request']; - self::assertSame( - 'http://169.254.169.254/metadata?api-version=2019-11-01&format=text', - $request->getUri()->__toString() - ); - self::assertSame('GET', $request->getMethod()); - self::assertSame('true', $request->getHeader('Metadata')[0]); - } - - public function testCheckUsabilityFailure(): void - { - $requestHandler = self::createRequestHandler($requestsHistory, [ - new Response( - 500, - ['Content-Type' => 'application/json'], - '' - ), - new Response( - 500, - ['Content-Type' => 'application/json'], - '' - ), - ]); - - $apiClientFactory = new PlainAzureApiClientFactory([ - 'requestHandler' => $requestHandler, - ]); - - $auth = new ManagedCredentialsAuthenticator($apiClientFactory, $this->logger); - - $this->expectExceptionMessage( - 'Instance metadata service not available: Server error: ' . - '`GET http://169.254.169.254/metadata?api-version=2019-11-01&format=text` resulted in a ' . - '`500 Internal Server Error` response' - ); - $this->expectException(ClientException::class); - $auth->checkUsability(); - } - /** * @param list $requestsHistory * @param list $responses diff --git a/libs/azure-api-client/tests/Authentication/Authenticator/StaticTokenAuthenticatorTest.php b/libs/azure-api-client/tests/Authentication/Authenticator/StaticTokenAuthenticatorTest.php new file mode 100644 index 000000000..51afd94e7 --- /dev/null +++ b/libs/azure-api-client/tests/Authentication/Authenticator/StaticTokenAuthenticatorTest.php @@ -0,0 +1,20 @@ +getAuthenticationToken('foo-resource'); + + self::assertSame('my-token', $token->value); + self::assertNull($token->expiresAt); + } +} diff --git a/libs/azure-api-client/tests/Authentication/Authenticator/SystemAuthenticatorResolverTest.php b/libs/azure-api-client/tests/Authentication/Authenticator/SystemAuthenticatorResolverTest.php new file mode 100644 index 000000000..15423b6ee --- /dev/null +++ b/libs/azure-api-client/tests/Authentication/Authenticator/SystemAuthenticatorResolverTest.php @@ -0,0 +1,116 @@ +logsHandler = new TestHandler(); + $this->logger = new Logger('tests', [$this->logsHandler]); + + putenv('AZURE_TENANT_ID'); + putenv('AZURE_CLIENT_ID'); + putenv('AZURE_CLIENT_SECRET'); + } + + public function testClientCredentialsAuthenticatorIsUsedWhenEnvIsSet(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response( + 200, + ['Content-Type' => 'application/json'], + (string) file_get_contents(__DIR__ . '/arm-metadata.json'), + ), + new Response( + 200, + ['Content-Type' => 'application/json'], + '{ + "token_type": "Bearer", + "expires_in": 3599, + "resource": "https://vault.azure.net", + "access_token": "ey....ey" + }' + ), + ]); + + $auth = new SystemAuthenticatorResolver( + requestHandler: $requestHandler(...), + logger: $this->logger, + ); + + putenv('AZURE_TENANT_ID=tenant-id'); + putenv('AZURE_CLIENT_ID=client-id'); + putenv('AZURE_CLIENT_SECRET=client-secret-id'); + + $token = $auth->getAuthenticationToken('resource-id'); + + self::assertSame('ey....ey', $token->value); + self::assertEqualsWithDelta(time() + 3599, $token->expiresAt?->getTimestamp(), 1); + self::assertTrue($this->logsHandler->hasDebug( + 'Found Azure client credentials in ENV, using ClientCredentialsAuthenticator' + )); + } + + public function testManagedCredentialsAuthenticatorIsUsedAsFallback(): void + { + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response( + 200, + ['Content-Type' => 'application/json'], + '{ + "token_type": "Bearer", + "expires_in": 3599, + "resource": "https://vault.azure.net", + "access_token": "ey....ey" + }' + ), + ]); + + $auth = new SystemAuthenticatorResolver( + requestHandler: $requestHandler(...), + logger: $this->logger, + ); + + $token = $auth->getAuthenticationToken('resource-id'); + + self::assertSame('ey....ey', $token->value); + self::assertEqualsWithDelta(time() + 3599, $token->expiresAt?->getTimestamp(), 1); + self::assertTrue($this->logsHandler->hasDebug( + 'Azure client credentials not found in ENV, using ManagedCredentialsAuthenticator' + )); + } + + /** + * @param list $requestsHistory + * @param list $responses + * @return HandlerStack + */ + private static function createRequestHandler(?array &$requestsHistory, array $responses): HandlerStack + { + $requestsHistory = []; + + $stack = HandlerStack::create(new MockHandler($responses)); + $stack->push(Middleware::history($requestsHistory)); + + return $stack; + } +} diff --git a/libs/azure-api-client/tests/Authentication/Authenticator/arm-metadata.json b/libs/azure-api-client/tests/Authentication/Authenticator/arm-metadata.json new file mode 100644 index 000000000..a4db309af --- /dev/null +++ b/libs/azure-api-client/tests/Authentication/Authenticator/arm-metadata.json @@ -0,0 +1,33 @@ +[ + { + "portal": "https://portal.azure.com", + "authentication": { + "loginEndpoint": "https://login.windows.net/", + "audiences": [ + "https://management.core.windows.net/", + "https://management.azure.com/" + ], + "tenant": "common", + "identityProvider": "AAD" + }, + "media": "https://rest.media.azure.net", + "graphAudience": "https://graph.windows.net/", + "graph": "https://graph.windows.net/", + "name": "AzureCloud", + "suffixes": { + "azureDataLakeStoreFileSystem": "azuredatalakestore.net", + "acrLoginServer": "azurecr.io", + "sqlServerHostname": "database.windows.net", + "azureDataLakeAnalyticsCatalogAndJob": "azuredatalakeanalytics.net", + "keyVaultDns": "vault.azure.net", + "storage": "core.windows.net", + "azureFrontDoorEndpointSuffix": "azurefd.net" + }, + "batch": "https://batch.core.windows.net/", + "resourceManager": "https://management.azure.com/", + "vmImageAliasDoc": "https://raw.githubusercontent.com/Azure/azure-rest-api-specs/master/arm-compute/quickstart-templates/aliases.json", + "activeDirectoryDataLake": "https://datalake.azure.net/", + "sqlManagement": "https://management.core.windows.net:8443/", + "gallery": "https://gallery.azure.com/" + } +] diff --git a/libs/azure-api-client/tests/ApiClientFactory/AuthorizationHeaderResolverTest.php b/libs/azure-api-client/tests/Authentication/AuthorizationHeaderResolverTest.php similarity index 72% rename from libs/azure-api-client/tests/ApiClientFactory/AuthorizationHeaderResolverTest.php rename to libs/azure-api-client/tests/Authentication/AuthorizationHeaderResolverTest.php index 73b326d29..579edfa19 100644 --- a/libs/azure-api-client/tests/ApiClientFactory/AuthorizationHeaderResolverTest.php +++ b/libs/azure-api-client/tests/Authentication/AuthorizationHeaderResolverTest.php @@ -2,14 +2,13 @@ declare(strict_types=1); -namespace Keboola\AzureApiClient\Tests\ApiClientFactory; +namespace Keboola\AzureApiClient\Tests\Authentication; use DateTimeImmutable; use GuzzleHttp\Psr7\Request; -use Keboola\AzureApiClient\ApiClientFactory\AuthorizationHeaderResolver; -use Keboola\AzureApiClient\Authentication\AuthenticatorFactory; -use Keboola\AzureApiClient\Authentication\AuthenticatorInterface; -use Keboola\AzureApiClient\Authentication\TokenResponse; +use Keboola\AzureApiClient\Authentication\AuthenticationToken; +use Keboola\AzureApiClient\Authentication\Authenticator\AuthenticatorInterface; +use Keboola\AzureApiClient\Authentication\AuthorizationHeaderResolver; use PHPUnit\Framework\TestCase; class AuthorizationHeaderResolverTest extends TestCase @@ -22,20 +21,14 @@ public function testTokenResolve(): void $authenticator ->method('getAuthenticationToken') ->with('foo-resource') - ->willReturn(new TokenResponse( + ->willReturn(new AuthenticationToken( 'auth-token', new DateTimeImmutable('+1 hour'), )) ; - $authenticatorFactory = $this->createMock(AuthenticatorFactory::class); - $authenticatorFactory->expects(self::once()) - ->method('createAuthenticator') - ->willReturn($authenticator) - ; - $resolver = new AuthorizationHeaderResolver( - $authenticatorFactory, + $authenticator, 'foo-resource', ); @@ -52,25 +45,19 @@ public function testTokenIsRefreshesBeforeExpiration(): void ->method('getAuthenticationToken') ->with('foo-resource') ->willReturnOnConsecutiveCalls( - new TokenResponse( + new AuthenticationToken( 'auth-token1', new DateTimeImmutable('+63 seconds'), // 60 seconds is EXPIRATION_MARGIN ), - new TokenResponse( + new AuthenticationToken( 'auth-token2', new DateTimeImmutable('+1 hour'), ), ) ; - $authenticatorFactory = $this->createMock(AuthenticatorFactory::class); - $authenticatorFactory->expects(self::once()) - ->method('createAuthenticator') - ->willReturn($authenticator) - ; - $resolver = new AuthorizationHeaderResolver( - $authenticatorFactory, + $authenticator, 'foo-resource', ); diff --git a/libs/azure-api-client/tests/Authentication/MetadataResponseTest.php b/libs/azure-api-client/tests/Authentication/Model/MetadataResponseTest.php similarity index 94% rename from libs/azure-api-client/tests/Authentication/MetadataResponseTest.php rename to libs/azure-api-client/tests/Authentication/Model/MetadataResponseTest.php index 31392412b..9be518cd0 100644 --- a/libs/azure-api-client/tests/Authentication/MetadataResponseTest.php +++ b/libs/azure-api-client/tests/Authentication/Model/MetadataResponseTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Keboola\AzureApiClient\Tests\Authentication; +namespace Keboola\AzureApiClient\Tests\Authentication\Model; -use Keboola\AzureApiClient\Authentication\MetadataResponse; +use Keboola\AzureApiClient\Authentication\Model\MetadataResponse; use PHPUnit\Framework\TestCase; class MetadataResponseTest extends TestCase diff --git a/libs/azure-api-client/tests/Authentication/TokenResponseTest.php b/libs/azure-api-client/tests/Authentication/Model/TokenResponseTest.php similarity index 91% rename from libs/azure-api-client/tests/Authentication/TokenResponseTest.php rename to libs/azure-api-client/tests/Authentication/Model/TokenResponseTest.php index 4e8429b68..62a6427f5 100644 --- a/libs/azure-api-client/tests/Authentication/TokenResponseTest.php +++ b/libs/azure-api-client/tests/Authentication/Model/TokenResponseTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Keboola\AzureApiClient\Tests\Authentication; +namespace Keboola\AzureApiClient\Tests\Authentication\Model; -use Keboola\AzureApiClient\Authentication\TokenResponse; +use Keboola\AzureApiClient\Authentication\Model\TokenResponse; use Keboola\AzureApiClient\Exception\ClientException; use PHPUnit\Framework\TestCase; diff --git a/libs/azure-api-client/tests/BaseTest.php b/libs/azure-api-client/tests/BaseTest.php deleted file mode 100644 index e81004393..000000000 --- a/libs/azure-api-client/tests/BaseTest.php +++ /dev/null @@ -1,59 +0,0 @@ - 'https://portal.azure.com', - 'authentication' => [ - 'loginEndpoint' => 'https://login.windows.net/', - 'audiences' => [ - 'https://management.core.windows.net/', - 'https://management.azure.com/', - ], - 'tenant' => 'common', - 'identityProvider' => 'AAD', - ], - 'media' => 'https://rest.media.azure.net', - 'graphAudience' => 'https://graph.windows.net/', - 'graph' => 'https://graph.windows.net/', - 'name' => 'AzureCloud', - 'suffixes' => [ - 'azureDataLakeStoreFileSystem' => 'azuredatalakestore.net', - 'acrLoginServer' => 'azurecr.io', - 'sqlServerHostname' => 'database.windows.net', - 'azureDataLakeAnalyticsCatalogAndJob' => 'azuredatalakeanalytics.net', - 'keyVaultDns' => 'vault.azure.net', - 'storage' => 'core.windows.net', - 'azureFrontDoorEndpointSuffix' => 'azurefd.net', - ], - 'batch' => 'https://batch.core.windows.net/', - 'resourceManager' => 'https://management.azure.com/', - // phpcs:ignore Generic.Files.LineLength - 'vmImageAliasDoc' => 'https://raw.githubusercontent.com/Azure/azure-rest-api-specs/master/arm-compute/quickstart-templates/aliases.json', - 'activeDirectoryDataLake' => 'https://datalake.azure.net/', - 'sqlManagement' => 'https://management.core.windows.net:8443/', - 'gallery' => 'https://gallery.azure.com/', - ], - ]; - } -} diff --git a/libs/azure-api-client/tests/Marketplace/MarketplaceApiClientTest.php b/libs/azure-api-client/tests/Marketplace/MarketplaceApiClientTest.php index 562b69597..6fd2f7431 100644 --- a/libs/azure-api-client/tests/Marketplace/MarketplaceApiClientTest.php +++ b/libs/azure-api-client/tests/Marketplace/MarketplaceApiClientTest.php @@ -4,42 +4,25 @@ namespace Keboola\AzureApiClient\Tests\Marketplace; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; use GuzzleHttp\Psr7\Request; -use Keboola\AzureApiClient\ApiClient; -use Keboola\AzureApiClient\ApiClientFactory\AuthenticatedAzureApiClientFactory; +use GuzzleHttp\Psr7\Response; +use Keboola\AzureApiClient\Authentication\Authenticator\StaticTokenAuthenticator; use Keboola\AzureApiClient\Json; use Keboola\AzureApiClient\Marketplace\MarketplaceApiClient; use Keboola\AzureApiClient\Marketplace\Model\ActivateSubscriptionRequest; use Keboola\AzureApiClient\Marketplace\Model\ResolveSubscriptionResult; use Keboola\AzureApiClient\Marketplace\Model\Subscription; use Keboola\AzureApiClient\Marketplace\OperationStatus; -use Keboola\AzureApiClient\Tests\ReflectionPropertyAccessTestCase; -use PHPUnit\Framework\Constraint\Callback; use PHPUnit\Framework\TestCase; class MarketplaceApiClientTest extends TestCase { - use ReflectionPropertyAccessTestCase; - - public function testCreateClient(): void - { - $azureApiClient = $this->createMock(ApiClient::class); - - $clientFactory = $this->createMock(AuthenticatedAzureApiClientFactory::class); - $clientFactory->expects(self::once()) - ->method('createClient') - ->with('https://marketplaceapi.microsoft.com', '20e940b3-4c77-4b0b-9a53-9e16a1b010a7') - ->willReturn($azureApiClient) - ; - - $client = MarketplaceApiClient::create($clientFactory); - - self::assertSame($azureApiClient, self::getPrivatePropertyValue($client, 'apiClient')); - } - public function testResolveSubscription(): void { - $apiResponse = ResolveSubscriptionResult::fromResponseData([ + $subscriptionData = [ 'id' => 'subscription-id', 'subscriptionName' => 'subscription-name', 'offerId' => 'offer-id', @@ -53,30 +36,43 @@ public function testResolveSubscription(): void 'quantity' => 1, 'saasSubscriptionStatus' => 'status', ], + ]; + + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response( + 200, + ['Content-Type' => 'application/json'], + Json::encodeArray($subscriptionData) + ), ]); - $azureApiClient = $this->createMock(ApiClient::class); - $azureApiClient->expects(self::once()) - ->method('sendRequestAndMapResponse') - ->with(self::checkRequestEquals( - 'POST', - '/api/saas/subscriptions/resolve?api-version=2018-08-31', - [ - 'x-ms-marketplace-token' => ['marketplace-token'], - ] - )) - ->willReturn($apiResponse) - ; - - $client = new MarketplaceApiClient($azureApiClient); + $client = new MarketplaceApiClient( + authenticator: new StaticTokenAuthenticator('my-token'), + requestHandler: $requestHandler, + ); $result = $client->resolveSubscription('marketplace-token'); - self::assertSame($apiResponse, $result); + self::assertEquals( + ResolveSubscriptionResult::fromResponseData($subscriptionData), + $result + ); + + self::assertCount(1, $requestsHistory); + self::assertRequestEquals( + 'POST', + 'https://marketplaceapi.microsoft.com/api/saas/subscriptions/resolve?api-version=2018-08-31', + [ + 'authorization' => 'Bearer my-token', + 'x-ms-marketplace-token' => 'marketplace-token', + ], + null, + $requestsHistory[0]['request'], + ); } public function testGetSubscription(): void { - $apiResponse = Subscription::fromResponseData([ + $subscriptionData = [ 'id' => 'subscription id', 'publisherId' => 'publisher-id', 'offerId' => 'offer-id', @@ -84,90 +80,133 @@ public function testGetSubscription(): void 'name' => 'subscription-name', 'quantity' => 1, 'saasSubscriptionStatus' => 'status', + ]; + + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response( + 200, + ['Content-Type' => 'application/json'], + Json::encodeArray($subscriptionData) + ), ]); - $azureApiClient = $this->createMock(ApiClient::class); - $azureApiClient->expects(self::once()) - ->method('sendRequestAndMapResponse') - ->with(self::checkRequestEquals( - 'GET', - '/api/saas/subscriptions/subscription+id?api-version=2018-08-31', - )) - ->willReturn($apiResponse) - ; - - $client = new MarketplaceApiClient($azureApiClient); + $client = new MarketplaceApiClient( + authenticator: new StaticTokenAuthenticator('my-token'), + requestHandler: $requestHandler, + ); $result = $client->getSubscription('subscription id'); - self::assertSame($apiResponse, $result); + self::assertEquals( + Subscription::fromResponseData($subscriptionData), + $result + ); + + self::assertCount(1, $requestsHistory); + self::assertRequestEquals( + 'GET', + 'https://marketplaceapi.microsoft.com/api/saas/subscriptions/subscription+id?api-version=2018-08-31', + [ + 'Authorization' => 'Bearer my-token', + ], + null, + $requestsHistory[0]['request'], + ); } public function testActivateSubscription(): void { - $azureApiClient = $this->createMock(ApiClient::class); - $azureApiClient->expects(self::once()) - ->method('sendRequest') - ->with(self::checkRequestEquals( - 'POST', - '/api/saas/subscriptions/subscription+id/activate?api-version=2018-08-31', - [ - 'Content-Type' => ['application/json'], - ], - Json::encodeArray([ - 'planId' => 'plan-id', - 'quantity' => 1, - ]), - )) - ; - - $client = new MarketplaceApiClient($azureApiClient); + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response(200), + ]); + + $client = new MarketplaceApiClient( + authenticator: new StaticTokenAuthenticator('my-token'), + requestHandler: $requestHandler, + ); $client->activateSubscription(new ActivateSubscriptionRequest( 'subscription id', 'plan-id', 1, )); + + self::assertCount(1, $requestsHistory); + self::assertRequestEquals( + 'POST', + // phpcs:ignore Generic.Files.LineLength + 'https://marketplaceapi.microsoft.com/api/saas/subscriptions/subscription+id/activate?api-version=2018-08-31', + [ + 'Authorization' => 'Bearer my-token', + 'Content-Type' => 'application/json', + ], + Json::encodeArray([ + 'planId' => 'plan-id', + 'quantity' => 1, + ]), + $requestsHistory[0]['request'], + ); } public function testUpdateOperationStatus(): void { - $azureApiClient = $this->createMock(ApiClient::class); - $azureApiClient->expects(self::once()) - ->method('sendRequest') - ->with(self::checkRequestEquals( - 'PATCH', - '/api/saas/subscriptions/subscription+id/operations/operation+id?api-version=2018-08-31', - [ - 'Content-Type' => ['application/json'], - ], - Json::encodeArray([ - 'status' => 'Success', - ]), - )) - ; - - $client = new MarketplaceApiClient($azureApiClient); + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response(200), + ]); + + $client = new MarketplaceApiClient( + authenticator: new StaticTokenAuthenticator('my-token'), + requestHandler: $requestHandler, + ); $client->updateOperationStatus( 'subscription id', 'operation id', OperationStatus::SUCCESS, ); + + self::assertCount(1, $requestsHistory); + self::assertRequestEquals( + 'PATCH', + // phpcs:ignore Generic.Files.LineLength + 'https://marketplaceapi.microsoft.com/api/saas/subscriptions/subscription+id/operations/operation+id?api-version=2018-08-31', + [ + 'Authorization' => 'Bearer my-token', + 'Content-Type' => 'application/json', + ], + Json::encodeArray([ + 'status' => 'Success', + ]), + $requestsHistory[0]['request'], + ); } /** - * @return Callback + * @param list $requestsHistory + * @param list $responses + * @return HandlerStack */ - private static function checkRequestEquals( + private static function createRequestHandler(?array &$requestsHistory, array $responses): HandlerStack + { + $requestsHistory = []; + + $stack = HandlerStack::create(new MockHandler($responses)); + $stack->push(Middleware::history($requestsHistory)); + + return $stack; + } + + private static function assertRequestEquals( string $method, string $uri, - array $headers = [], - ?string $body = null - ): Callback { - return self::callback(function (Request $request) use ($method, $uri, $headers, $body) { - self::assertSame($method, $request->getMethod()); - self::assertSame($uri, $request->getUri()->__toString()); - self::assertSame($headers, $request->getHeaders()); - self::assertSame($body ?? '', $request->getBody()->getContents()); - return true; - }); + array $headers, + ?string $body, + Request $request, + ): void { + self::assertSame($method, $request->getMethod()); + self::assertSame($uri, $request->getUri()->__toString()); + + foreach ($headers as $headerName => $headerValue) { + self::assertSame($headerValue, $request->getHeaderLine($headerName)); + } + + self::assertSame($body ?? '', $request->getBody()->getContents()); } } diff --git a/libs/azure-api-client/tests/Marketplace/MeteringServiceApiClientTest.php b/libs/azure-api-client/tests/Marketplace/MeteringServiceApiClientTest.php index 29a52cf8c..7873b2826 100644 --- a/libs/azure-api-client/tests/Marketplace/MeteringServiceApiClientTest.php +++ b/libs/azure-api-client/tests/Marketplace/MeteringServiceApiClientTest.php @@ -5,37 +5,21 @@ namespace Keboola\AzureApiClient\Tests\Marketplace; use DateTimeImmutable; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; use GuzzleHttp\Psr7\Request; -use Keboola\AzureApiClient\ApiClient; -use Keboola\AzureApiClient\ApiClientFactory\AuthenticatedAzureApiClientFactory; +use GuzzleHttp\Psr7\Response; +use Keboola\AzureApiClient\Authentication\Authenticator\StaticTokenAuthenticator; use Keboola\AzureApiClient\Json; use Keboola\AzureApiClient\Marketplace\MeteringServiceApiClient; -use Keboola\AzureApiClient\Marketplace\Model\ReportUsageEventsBatchResult; use Keboola\AzureApiClient\Marketplace\Model\UsageEvent; use Keboola\AzureApiClient\Marketplace\Model\UsageEventResult; -use Keboola\AzureApiClient\Tests\ReflectionPropertyAccessTestCase; -use PHPUnit\Framework\Constraint\Callback; use PHPUnit\Framework\TestCase; class MeteringServiceApiClientTest extends TestCase { - use ReflectionPropertyAccessTestCase; - public function testCreateClient(): void - { - $azureApiClient = $this->createMock(ApiClient::class); - - $clientFactory = $this->createMock(AuthenticatedAzureApiClientFactory::class); - $clientFactory->expects(self::once()) - ->method('createClient') - ->with('https://marketplaceapi.microsoft.com/api/', '20e940b3-4c77-4b0b-9a53-9e16a1b010a7') - ->willReturn($azureApiClient) - ; - - $client = MeteringServiceApiClient::create($clientFactory); - - self::assertSame($azureApiClient, self::getPrivatePropertyValue($client, 'apiClient')); - } public function testReportUsageEventsBatch(): void { @@ -68,38 +52,18 @@ public function testReportUsageEventsBatch(): void ], ]; - $azureApiClient = $this->createMock(ApiClient::class); - $azureApiClient->expects(self::once()) - ->method('sendRequestAndMapResponse') - ->with(self::checkRequestEquals( - 'POST', - 'batchUsageEvent?api-version=2018-08-31', - [ - 'Content-Type' => ['application/json'], - ], - Json::encodeArray([ - 'request' => [ - [ - 'resourceId' => 'resource-1', - 'planId' => 'plan-1', - 'dimension' => 'dim-1', - 'quantity' => 1.0, - 'effectiveStartTime' => '2023-01-01T12:00:00+00:00', - ], - [ - 'resourceId' => 'resource-2', - 'planId' => 'plan-2', - 'dimension' => 'dim-2', - 'quantity' => 2.5, - 'effectiveStartTime' => '2023-01-02T12:00:00+00:00', - ], - ], - ]), - )) - ->willReturn(ReportUsageEventsBatchResult::fromResponseData($apiResponse)) - ; + $requestHandler = self::createRequestHandler($requestsHistory, [ + new Response( + 200, + ['Content-Type' => 'application/json'], + Json::encodeArray($apiResponse) + ), + ]); - $client = new MeteringServiceApiClient($azureApiClient); + $client = new MeteringServiceApiClient( + authenticator: new StaticTokenAuthenticator('my-token'), + requestHandler: $requestHandler, + ); $result = $client->reportUsageEventsBatch([ new UsageEvent( 'resource-1', @@ -121,23 +85,66 @@ public function testReportUsageEventsBatch(): void UsageEventResult::fromResponseData($apiResponse['result'][0]), UsageEventResult::fromResponseData($apiResponse['result'][1]), ], $result); + + self::assertCount(1, $requestsHistory); + self::assertRequestEquals( + 'POST', + 'https://marketplaceapi.microsoft.com/api/batchUsageEvent?api-version=2018-08-31', + [ + 'Authorization' => 'Bearer my-token', + 'Content-Type' => 'application/json', + ], + Json::encodeArray([ + 'request' => [ + [ + 'resourceId' => 'resource-1', + 'planId' => 'plan-1', + 'dimension' => 'dim-1', + 'quantity' => 1.0, + 'effectiveStartTime' => '2023-01-01T12:00:00+00:00', + ], + [ + 'resourceId' => 'resource-2', + 'planId' => 'plan-2', + 'dimension' => 'dim-2', + 'quantity' => 2.5, + 'effectiveStartTime' => '2023-01-02T12:00:00+00:00', + ], + ], + ]), + $requestsHistory[0]['request'], + ); } /** - * @return Callback + * @param list $requestsHistory + * @param list $responses + * @return HandlerStack */ - private static function checkRequestEquals( + private static function createRequestHandler(?array &$requestsHistory, array $responses): HandlerStack + { + $requestsHistory = []; + + $stack = HandlerStack::create(new MockHandler($responses)); + $stack->push(Middleware::history($requestsHistory)); + + return $stack; + } + + private static function assertRequestEquals( string $method, string $uri, - array $headers = [], - ?string $body = null - ): Callback { - return self::callback(function (Request $request) use ($method, $uri, $headers, $body) { - self::assertSame($method, $request->getMethod()); - self::assertSame($uri, $request->getUri()->__toString()); - self::assertSame($headers, $request->getHeaders()); - self::assertSame($body ?? '', $request->getBody()->getContents()); - return true; - }); + array $headers, + ?string $body, + Request $request, + ): void { + self::assertSame($method, $request->getMethod()); + self::assertSame($uri, $request->getUri()->__toString()); + + foreach ($headers as $headerName => $headerValue) { + self::assertSame($headerValue, $request->getHeaderLine($headerName)); + } + + self::assertSame($body ?? '', $request->getBody()->getContents()); } }