diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 0911274..3203d73 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -10,6 +10,7 @@ env: CLIENT_ID: '${{ secrets.CLIENT_ID }}' CLIENT_SECRET: '${{ secrets.CLIENT_SECRET }}' REFRESH_TOKEN: '${{ secrets.REFRESH_TOKEN }}' + SERVICE_ACCOUNT_JSON: '${{ secrets.SERVICE_ACCOUNT_JSON }}' jobs: tests: runs-on: ubuntu-latest @@ -31,4 +32,5 @@ jobs: -e CLIENT_ID \ -e CLIENT_SECRET \ -e REFRESH_TOKEN \ - ${{env.APP_IMAGE}} composer ci \ No newline at end of file + -e SERVICE_ACCOUNT_JSON \ + ${{env.APP_IMAGE}} composer ci diff --git a/README.md b/README.md index ea88416..4b7a414 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,183 @@ -# Keboola Google API Client +# Keboola Google Client Bundle -Basic client for Google APIs +Keboola Google API Client with OAuth 2.0 and Service Account authentication support. + +## Installation + +```bash +composer require keboola/google-client-bundle +``` + +## Usage + +This library supports two types of authentication: + +1. **OAuth 2.0** - for applications that need access to user data +2. **Service Account** - for server-to-server communication without user intervention + +### OAuth 2.0 Authentication + +#### Classic approach + +```php +use Keboola\Google\ClientBundle\Google\RestApi; + +$api = new RestApi($logger); // Optional logger parameter +$api->setAppCredentials($clientId, $clientSecret); +$api->setCredentials($accessToken, $refreshToken); + +// Get authorization URL +$authUrl = $api->getAuthorizationUrl( + 'http://localhost/callback', + 'https://www.googleapis.com/auth/drive.readonly', + 'force', + 'offline' +); + +// Authorize using code +$tokens = $api->authorize($code, 'http://localhost/callback'); + +// API calls +$response = $api->request('/drive/v2/files'); +``` + +#### New factory approach + +```php +use Keboola\Google\ClientBundle\Google\RestApi; + +$api = RestApi::createWithOAuth( + $clientId, + $clientSecret, + $accessToken, + $refreshToken +); + +// Usage same as above +$response = $api->request('/drive/v2/files'); +``` + +### Service Account Authentication + +```php +use Keboola\Google\ClientBundle\Google\RestApi; + +// Service account JSON configuration (from Google Cloud Console) +$serviceAccountConfig = [ + 'type' => 'service_account', + 'project_id' => 'your-project-id', + 'private_key_id' => 'key-id', + 'private_key' => '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n', + 'client_email' => 'your-service-account@your-project.iam.gserviceaccount.com', + 'client_id' => '123456789', + 'auth_uri' => 'https://accounts.google.com/o/oauth2/auth', + 'token_uri' => 'https://oauth2.googleapis.com/token', + 'auth_provider_x509_cert_url' => 'https://www.googleapis.com/oauth2/v1/certs', + 'client_x509_cert_url' => 'https://www.googleapis.com/robot/v1/metadata/x509/your-service-account%40your-project.iam.gserviceaccount.com' +]; + +// Scope definitions +$scopes = [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/drive.readonly' +]; + +// Create API client +$api = RestApi::createWithServiceAccount( + $serviceAccountConfig, + $scopes +); + +// API calls +$response = $api->request('/drive/v2/files'); +``` + +#### Loading Service Account from JSON file + +```php +use Keboola\Google\ClientBundle\Google\RestApi; + +// Load from JSON file +$serviceAccountConfig = json_decode( + file_get_contents('/path/to/service-account-key.json'), + true +); + +$scopes = ['https://www.googleapis.com/auth/cloud-platform']; + +$api = RestApi::createWithServiceAccount($serviceAccountConfig, $scopes); +$response = $api->request('/your-api-endpoint'); +``` + +## Differences between OAuth and Service Account + +| Property | OAuth 2.0 | Service Account | +|----------|-----------|-----------------| +| Authentication type | User-based | Server-to-server | +| Refresh token | ✅ Yes | ❌ No (not needed) | +| Authorization | Requires user consent | Automatic | +| Usage | Access to user data | Access to application data | +| Token expiration | Based on refresh token | Automatic renewal | + +## Advanced Usage + +### Retry and Backoff + +```php +$api->setBackoffsCount(10); // Number of retries +$api->setDelayFn(function($retries) { + return 1000 * pow(2, $retries); // Exponential backoff +}); +``` + +### Logging + +```php +use Monolog\Logger; +use Monolog\Handler\StreamHandler; + +$logger = new Logger('google-api'); +$logger->pushHandler(new StreamHandler('php://stdout')); + +$api = RestApi::createWithServiceAccount( + $serviceAccountConfig, + $scopes, + $logger +); +``` + +### Custom HTTP Options + +```php +$response = $api->request('/endpoint', 'POST', [ + 'Content-Type' => 'application/json' +], [ + 'json' => ['key' => 'value'], + 'timeout' => 30 +]); +``` + +## Testing + +```bash +# OAuth tests (require environment variables) +export CLIENT_ID="your-client-id" +export CLIENT_SECRET="your-client-secret" +export REFRESH_TOKEN="your-refresh-token" + +# Service Account tests (optional) +export SERVICE_ACCOUNT_JSON='{"type":"service_account","project_id":"your-project",...}' + +# Run tests +composer tests +``` + +## Requirements + +- PHP ^7.1 +- guzzlehttp/guzzle ^6.0 +- google/auth ^1.26 ## License -MIT licensed, see [LICENSE](./LICENSE) file. +MIT diff --git a/composer.json b/composer.json index 25e27e8..198f71b 100644 --- a/composer.json +++ b/composer.json @@ -12,12 +12,13 @@ "require": { "php": "^8.1", "guzzlehttp/guzzle": "^7.0", - "monolog/monolog": "^2.1" + "monolog/monolog": "^2.1", + "google/auth": "^1.26" }, "require-dev": { "keboola/coding-standard": "^15.0", - "phpstan/phpstan": "^1.4", "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^9.5" }, "minimum-stability": "stable", @@ -28,7 +29,7 @@ } }, "scripts": { - "tests": "phpunit", + "tests": "phpunit --testdox", "phpstan": "phpstan analyse ./src ./tests --level=max --no-progress -c phpstan.neon", "phpcs": "phpcs -n --ignore=vendor --extensions=php .", "phpcbf": "phpcbf -n --ignore=vendor --extensions=php .", diff --git a/docker-compose.yml b/docker-compose.yml index b1a191e..b3f9bdf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - CLIENT_ID - CLIENT_SECRET - REFRESH_TOKEN + - SERVICE_ACCOUNT_JSON dev: build: . @@ -21,3 +22,4 @@ services: - CLIENT_ID - CLIENT_SECRET - REFRESH_TOKEN + - SERVICE_ACCOUNT_JSON diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..c3ecab2 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,205 @@ +parameters: + ignoreErrors: + - + message: '#^Binary operation "\+" between int\<1, max\> and mixed results in an error\.$#' + identifier: binaryOp.invalid + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Binary operation "\." between ''Bearer '' and mixed results in an error\.$#' + identifier: binaryOp.invalid + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Call to an undefined method GuzzleHttp\\Client\:\:options\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Cannot access offset ''access_token'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Cannot access offset ''expires_in'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Cannot call method fetchAuthToken\(\) on mixed\.$#' + identifier: method.nonObject + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Method Keboola\\Google\\ClientBundle\\Google\\RestApi\:\:authorize\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Method Keboola\\Google\\ClientBundle\\Google\\RestApi\:\:createWithServiceAccount\(\) has parameter \$scopes with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Method Keboola\\Google\\ClientBundle\\Google\\RestApi\:\:createWithServiceAccount\(\) has parameter \$serviceAccountConfig with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Method Keboola\\Google\\ClientBundle\\Google\\RestApi\:\:decideRetry\(\) should return bool but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Method Keboola\\Google\\ClientBundle\\Google\\RestApi\:\:getRefreshToken\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Method Keboola\\Google\\ClientBundle\\Google\\RestApi\:\:getServiceAccountAccessToken\(\) should return string but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Method Keboola\\Google\\ClientBundle\\Google\\RestApi\:\:refreshToken\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Method Keboola\\Google\\ClientBundle\\Google\\RestApi\:\:request\(\) has parameter \$addHeaders with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Method Keboola\\Google\\ClientBundle\\Google\\RestApi\:\:request\(\) has parameter \$options with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Method Keboola\\Google\\ClientBundle\\Google\\RestApi\:\:request\(\) should return GuzzleHttp\\Psr7\\Response but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Parameter \#1 \$retries of method Keboola\\Google\\ClientBundle\\Google\\RestApi\:\:decideRetry\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Parameter \#1 \$retries of method Keboola\\Google\\ClientBundle\\Google\\RestApi\:\:logRetryRequest\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Property Keboola\\Google\\ClientBundle\\Google\\RestApi\:\:\$scopes \(array\\|null\) does not accept array\.$#' + identifier: assign.propertyType + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Property Keboola\\Google\\ClientBundle\\Google\\RestApi\:\:\$serviceAccountAccessToken \(string\|null\) does not accept mixed\.$#' + identifier: assign.propertyType + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Property Keboola\\Google\\ClientBundle\\Google\\RestApi\:\:\$serviceAccountConfig type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Google/RestApi.php + + - + message: '#^Cannot call method then\(\) on mixed\.$#' + identifier: method.nonObject + count: 1 + path: src/Guzzle/RetryCallbackMiddleware.php + + - + message: '#^Cannot use \+\+ on mixed\.$#' + identifier: preInc.type + count: 1 + path: src/Guzzle/RetryCallbackMiddleware.php + + - + message: '#^Method Keboola\\Google\\ClientBundle\\Guzzle\\RetryCallbackMiddleware\:\:__invoke\(\) has parameter \$options with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Guzzle/RetryCallbackMiddleware.php + + - + message: '#^Method Keboola\\Google\\ClientBundle\\Guzzle\\RetryCallbackMiddleware\:\:__invoke\(\) should return GuzzleHttp\\Promise\\PromiseInterface but returns mixed\.$#' + identifier: return.type + count: 1 + path: src/Guzzle/RetryCallbackMiddleware.php + + - + message: '#^Method Keboola\\Google\\ClientBundle\\Guzzle\\RetryCallbackMiddleware\:\:doRetry\(\) has parameter \$options with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Guzzle/RetryCallbackMiddleware.php + + - + message: '#^Method Keboola\\Google\\ClientBundle\\Guzzle\\RetryCallbackMiddleware\:\:onFulfilled\(\) has parameter \$options with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Guzzle/RetryCallbackMiddleware.php + + - + message: '#^Method Keboola\\Google\\ClientBundle\\Guzzle\\RetryCallbackMiddleware\:\:onRejected\(\) has parameter \$options with no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: src/Guzzle/RetryCallbackMiddleware.php + + - + message: '#^Parameter \#1 \$request of method Keboola\\Google\\ClientBundle\\Guzzle\\RetryCallbackMiddleware\:\:doRetry\(\) expects Psr\\Http\\Message\\RequestInterface, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Guzzle/RetryCallbackMiddleware.php + + - + message: '#^Binary operation "\-" between mixed and 1 results in an error\.$#' + identifier: binaryOp.invalid + count: 1 + path: tests/RestApiTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsString\(\) with non\-falsy\-string will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: tests/RestApiTest.php + + - + message: '#^Method Keboola\\Google\\ClientBundle\\Tests\\RestApiTest\:\:getServiceAccountConfig\(\) return type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue + count: 1 + path: tests/RestApiTest.php + + - + message: '#^Method Keboola\\Google\\ClientBundle\\Tests\\RestApiTest\:\:getServiceAccountConfig\(\) should return array but returns mixed\.$#' + identifier: return.type + count: 1 + path: tests/RestApiTest.php + + - + message: '#^Parameter \#2 \$array of method PHPUnit\\Framework\\Assert\:\:assertArrayHasKey\(\) expects array\|ArrayAccess, mixed given\.$#' + identifier: argument.type + count: 6 + path: tests/RestApiTest.php diff --git a/phpstan.neon b/phpstan.neon index cd7b009..4267419 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,3 +1,5 @@ +includes: + - phpstan-baseline.neon parameters: - checkMissingIterableValueType: false + level: max ignoreErrors: diff --git a/src/Google/RestApi.php b/src/Google/RestApi.php index b3e8cef..3728121 100644 --- a/src/Google/RestApi.php +++ b/src/Google/RestApi.php @@ -4,15 +4,17 @@ namespace Keboola\Google\ClientBundle\Google; +use Google\Auth\CredentialsLoader; use GuzzleHttp\Client; use GuzzleHttp\Handler\CurlHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; use Keboola\Google\ClientBundle\Exception\RestApiException; use Keboola\Google\ClientBundle\Guzzle\RetryCallbackMiddleware; -use Monolog\Logger; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Log\LoggerInterface; +use Throwable; class RestApi { @@ -21,6 +23,9 @@ class RestApi private const DEFAULT_CONNECT_TIMEOUT = 30; private const DEFAULT_REQUEST_TIMEOUT = 5 * 60; + public const AUTH_TYPE_OAUTH = 'oauth'; + public const AUTH_TYPE_SERVICE_ACCOUNT = 'service_account'; + /** @var int */ protected $maxBackoffs = 7; @@ -28,10 +33,10 @@ class RestApi protected $backoffCallback403; /** @var string */ - protected $accessToken; + protected $accessToken = ''; /** @var string */ - protected $refreshToken; + protected $refreshToken = ''; /** @var string */ protected $clientId; @@ -45,24 +50,125 @@ class RestApi /** @var callable */ protected $delayFn = null; - /** @var ?Logger */ + /** @var ?LoggerInterface */ protected $logger; - public function __construct( + /** @var string */ + protected $authType = self::AUTH_TYPE_OAUTH; + + /** @var ?array */ + protected $serviceAccountConfig; + + /** @var ?array */ + protected $scopes; + + /** @var mixed */ + protected $serviceAccountCredentials; + + /** @var ?string */ + protected $serviceAccountAccessToken; + + /** @var ?int */ + protected $serviceAccountTokenExpiry; + + public function __construct(?LoggerInterface $logger = null) + { + $this->logger = $logger; + + $this->backoffCallback403 = function () { + return true; + }; + } + + /** + * Factory method for creating REST API client with OAuth authentication + */ + public static function createWithOAuth( string $clientId, string $clientSecret, string $accessToken = '', string $refreshToken = '', - ?Logger $logger = null, - ) { - $this->clientId = $clientId; - $this->clientSecret = $clientSecret; - $this->setCredentials($accessToken, $refreshToken); - $this->logger = $logger; - - $this->backoffCallback403 = function () { + ?LoggerInterface $logger = null, + ): self { + $instance = new self($logger); + $instance->authType = self::AUTH_TYPE_OAUTH; + $instance->clientId = $clientId; + $instance->clientSecret = $clientSecret; + $instance->setCredentials($accessToken, $refreshToken); + + $instance->backoffCallback403 = function () { return true; }; + + return $instance; + } + + /** + * Factory method for creating REST API client with Service Account authentication + */ + public static function createWithServiceAccount( + array $serviceAccountConfig, + array $scopes, + ?LoggerInterface $logger = null, + ): self { + $instance = new self($logger); + $instance->authType = self::AUTH_TYPE_SERVICE_ACCOUNT; + $instance->serviceAccountConfig = $serviceAccountConfig; + $instance->scopes = $scopes; + $instance->initializeServiceAccountCredentials(); + return $instance; + } + + /** + * Initialize Service Account credentials using Google Auth SDK + */ + protected function initializeServiceAccountCredentials(): void + { + if ($this->serviceAccountConfig === null || empty($this->scopes)) { + throw new RestApiException('Service account configuration and scopes are required', 400); + } + + try { + $this->serviceAccountCredentials = CredentialsLoader::makeCredentials( + $this->scopes, + $this->serviceAccountConfig, + ); + } catch (Throwable $e) { + throw new RestApiException('Failed to initialize service account credentials: ' . $e->getMessage(), 500); + } + } + + /** + * Get access token for Service Account + */ + protected function getServiceAccountAccessToken(): string + { + // Return cached token if still valid + if ($this->serviceAccountAccessToken !== null && + $this->serviceAccountTokenExpiry !== null && + time() < $this->serviceAccountTokenExpiry - 60) { // 60s buffer + return $this->serviceAccountAccessToken; + } + + if ($this->serviceAccountCredentials === null) { + throw new RestApiException('Service account credentials not initialized', 500); + } + + try { + // Fetch new access token using Google Auth SDK + $authToken = $this->serviceAccountCredentials->fetchAuthToken(); + + if (!isset($authToken['access_token'])) { + throw new RestApiException('Failed to retrieve access token from service account', 500); + } + + $this->serviceAccountAccessToken = $authToken['access_token']; + $this->serviceAccountTokenExpiry = time() + ($authToken['expires_in'] ?? 3600); + + return $this->serviceAccountAccessToken; + } catch (Throwable $e) { + throw new RestApiException('Failed to fetch service account access token: ' . $e->getMessage(), 500); + } } public static function createRetryMiddleware( @@ -100,8 +206,16 @@ public function createRetryCallback(): callable ?ResponseInterface $response = null, ) use ($api) { if ($response && $response->getStatusCode() === 401) { - $tokens = $api->refreshToken(); - return $request->withHeader('Authorization', 'Bearer ' . $tokens['access_token']); + if ($api->authType === self::AUTH_TYPE_SERVICE_ACCOUNT) { + // For Service Account, get new access token + $api->serviceAccountAccessToken = null; // Clear cache + $accessToken = $api->getServiceAccountAccessToken(); + return $request->withHeader('Authorization', 'Bearer ' . $accessToken); + } else { + // For OAuth, use refresh token + $tokens = $api->refreshToken(); + return $request->withHeader('Authorization', 'Bearer ' . $tokens['access_token']); + } } return $request; }; @@ -148,14 +262,27 @@ public function setCredentials(string $accessToken, string $refreshToken): void public function getAccessToken(): string { + if ($this->authType === self::AUTH_TYPE_SERVICE_ACCOUNT) { + return $this->getServiceAccountAccessToken(); + } + return $this->accessToken; } public function getRefreshToken(): array { + if ($this->authType === self::AUTH_TYPE_SERVICE_ACCOUNT) { + throw new RestApiException('Refresh token is not applicable for service account authentication', 400); + } + return $this->refreshToken(); } + public function getAuthType(): string + { + return $this->authType; + } + public function setRefreshTokenCallback(callable $callback): void { $this->refreshTokenCallback = $callback; @@ -260,13 +387,21 @@ public function request( throw new RestApiException('Wrong http method specified', 500); } - if ($this->refreshToken === null) { - throw new RestApiException('Refresh token must be set', 400); + // Validate authentication based on type + if ($this->authType === self::AUTH_TYPE_OAUTH && $this->refreshToken === null) { + throw new RestApiException('Refresh token must be set for OAuth authentication', 400); + } elseif ($this->authType === self::AUTH_TYPE_SERVICE_ACCOUNT && + ($this->serviceAccountConfig === null || $this->scopes === null || empty($this->scopes)) + ) { + throw new RestApiException( + 'Service account configuration and scopes must be set for service account authentication', + 400, + ); } $headers = [ 'Accept' => 'application/json', - 'Authorization' => 'Bearer ' . $this->accessToken, + 'Authorization' => 'Bearer ' . $this->getAccessToken(), ]; foreach ($addHeaders as $k => $v) { @@ -306,11 +441,11 @@ protected function logRetryRequest( ]; $this->logger->info( - sprintf('Retrying request (%sx) - reason: %s', $retries, $response->getReasonPhrase()), + sprintf('Retrying request (%dx) - reason: %s', $retries, $response->getReasonPhrase()), $context, ); } else { - $this->logger->info(sprintf('Retrying request (%sx)', $retries), $context); + $this->logger->info(sprintf('Retrying request (%dx)', $retries), $context); } } } diff --git a/tests/RestApiTest.php b/tests/RestApiTest.php index d94191b..27ae242 100644 --- a/tests/RestApiTest.php +++ b/tests/RestApiTest.php @@ -6,10 +6,12 @@ use Exception; use GuzzleHttp\Exception\ClientException; +use Keboola\Google\ClientBundle\Exception\RestApiException; use Keboola\Google\ClientBundle\Google\RestApi; use Monolog\Handler\TestHandler; use Monolog\Logger; use PHPUnit\Framework\TestCase; +use ReflectionClass; use SebastianBergmann\Timer\Timer; class RestApiTest extends TestCase @@ -38,7 +40,23 @@ protected function initApi(): RestApi $this->logger = new Logger('Google Rest API tests'); $this->logger->pushHandler($this->testHandler); - return new RestApi( + $api = new RestApi($this->logger); + $api->setAppCredentials($this->clientId, $this->clientSecret); + $api->setCredentials('', $this->refreshToken); + + return $api; + } + + protected function initOAuthApi(): RestApi + { + $this->clientId = $this->getEnv('CLIENT_ID'); + $this->clientSecret = $this->getEnv('CLIENT_SECRET'); + $this->refreshToken = $this->getEnv('REFRESH_TOKEN'); + $this->testHandler = new TestHandler(); + $this->logger = new Logger('Google Rest API tests'); + $this->logger->pushHandler($this->testHandler); + + return RestApi::createWithOAuth( $this->clientId, $this->clientSecret, '', @@ -47,6 +65,28 @@ protected function initApi(): RestApi ); } + protected function getServiceAccountConfig(): array + { + $serviceAccountJson = $this->getEnv('SERVICE_ACCOUNT_JSON'); + return json_decode($serviceAccountJson, true); + } + + protected function initServiceAccountApi(): RestApi + { + $this->testHandler = new TestHandler(); + $this->logger = new Logger('Google Rest API tests'); + $this->logger->pushHandler($this->testHandler); + + $serviceAccountConfig = $this->getServiceAccountConfig(); + $scopes = ['https://www.googleapis.com/auth/cloud-platform']; + + return RestApi::createWithServiceAccount( + $serviceAccountConfig, + $scopes, + $this->logger, + ); + } + public function testGetAuthorizationUrl(): void { $restApi = $this->initApi(); @@ -63,6 +103,146 @@ public function testGetAuthorizationUrl(): void $this->assertEquals($expectedUrl, $url); } + public function testOAuthCreateWithOAuth(): void + { + $restApi = $this->initOAuthApi(); + $this->assertEquals(RestApi::AUTH_TYPE_OAUTH, $restApi->getAuthType()); + } + + public function testServiceAccountCreateWithServiceAccount(): void + { + if (!$this->hasServiceAccountCredentials()) { + $this->markTestSkipped('Service account credentials not available'); + } + + $restApi = $this->initServiceAccountApi(); + $this->assertEquals(RestApi::AUTH_TYPE_SERVICE_ACCOUNT, $restApi->getAuthType()); + } + + public function testServiceAccountGetRefreshTokenThrowsException(): void + { + if (!$this->hasServiceAccountCredentials()) { + $this->markTestSkipped('Service account credentials not available'); + } + + $restApi = $this->initServiceAccountApi(); + + $this->expectException(RestApiException::class); + $this->expectExceptionMessage('Refresh token is not applicable for service account authentication'); + + $restApi->getRefreshToken(); + } + + public function testServiceAccountAuthentication(): void + { + if (!$this->hasServiceAccountCredentials()) { + $this->markTestSkipped('Service account credentials not available'); + } + + $restApi = $this->initServiceAccountApi(); + $accessToken = $restApi->getAccessToken(); + + $this->assertNotEmpty($accessToken); + $this->assertIsString($accessToken); + } + + public function testServiceAccountRequest(): void + { + if (!$this->hasServiceAccountCredentials()) { + $this->markTestSkipped('Service account credentials not available'); + } + + $restApi = $this->initServiceAccountApi(); + $serviceAccountConfig = $this->getServiceAccountConfig(); + $serviceAccountEmail = $serviceAccountConfig['client_email']; + $response = $restApi->request(sprintf( + '/oauth2/v3/tokeninfo?access_token=%s', + $restApi->getAccessToken(), + )); + + $body = json_decode($response->getBody()->getContents(), true); + + $this->assertArrayHasKey('azp', $body); + $this->assertArrayHasKey('aud', $body); + $this->assertArrayHasKey('access_type', $body); + } + + public function testServiceAccountTokenCaching(): void + { + if (!$this->hasServiceAccountCredentials()) { + $this->markTestSkipped('Service account credentials not available'); + } + + $restApi = $this->initServiceAccountApi(); + + // Get token twice - should be cached + $token1 = $restApi->getAccessToken(); + $token2 = $restApi->getAccessToken(); + + $this->assertEquals($token1, $token2); + $this->assertNotEmpty($token1); + } + + public function testServiceAccountInvalidConfiguration(): void + { + $this->testHandler = new TestHandler(); + $this->logger = new Logger('Google Rest API tests'); + $this->logger->pushHandler($this->testHandler); + + $invalidConfig = [ + 'type' => 'service_account', + 'project_id' => 'invalid-project', + // Missing required fields like private_key, client_email + ]; + + $this->expectException(RestApiException::class); + $this->expectExceptionMessage('Failed to initialize service account credentials'); + + RestApi::createWithServiceAccount( + $invalidConfig, + ['https://www.googleapis.com/auth/cloud-platform'], + $this->logger, + ); + } + + public function testServiceAccountRequestValidation(): void + { + // Create instance without proper configuration + $api = new RestApi(null); + + // Manually set auth type to service account but don't initialize properly + $reflection = new ReflectionClass($api); + $authTypeProperty = $reflection->getProperty('authType'); + $authTypeProperty->setAccessible(true); + $authTypeProperty->setValue($api, RestApi::AUTH_TYPE_SERVICE_ACCOUNT); + + $this->expectException(RestApiException::class); + $this->expectExceptionMessage( + 'Service account configuration and scopes must be set for service account authentication', + ); + + $api->request('/test-endpoint'); + } + + public function testServiceAccountWithoutRequiredScopes(): void + { + if (!$this->hasServiceAccountCredentials()) { + $this->markTestSkipped('Service account credentials not available'); + } + + $serviceAccountConfig = $this->getServiceAccountConfig(); + + $this->expectException(RestApiException::class); + $this->expectExceptionMessage('Service account configuration and scopes are required'); + + // Try to create with empty scopes + RestApi::createWithServiceAccount( + $serviceAccountConfig, + [], // Empty scopes + $this->logger, + ); + } + public function testRefreshToken(): void { $restApi = $this->initApi(); @@ -72,6 +252,15 @@ public function testRefreshToken(): void $this->assertNotEmpty($response['access_token']); } + public function testOAuthRefreshToken(): void + { + $restApi = $this->initOAuthApi(); + $response = $restApi->refreshToken(); + + $this->assertArrayHasKey('access_token', $response); + $this->assertNotEmpty($response['access_token']); + } + public function testRequest(): void { $restApi = $this->initApi(); @@ -84,6 +273,17 @@ public function testRequest(): void $this->assertArrayHasKey('name', $body); } + public function testOAuthRequest(): void + { + $restApi = $this->initOAuthApi(); + $response = $restApi->request('/oauth2/v3/userinfo'); + $body = json_decode($response->getBody()->getContents(), true); + + $this->assertArrayHasKey('sub', $body); + $this->assertArrayHasKey('email', $body); + $this->assertArrayHasKey('name', $body); + } + public function testDelayFn(): void { $timer = new Timer(); @@ -112,6 +312,7 @@ public function testDelayFn(): void public function testRetries(): void { $restApi = $this->initApi(); + $restApi->setBackoffsCount(2); try { $restApi->request('/auth/invalid-scope'); } catch (ClientException $e) { @@ -131,7 +332,6 @@ public function testRetries(): void 'Host' => ['www.googleapis.com'], 'Accept' => ['application/json'], 'Authorization' => '*****', - ], 'method' => 'GET', 'body' => '', @@ -139,13 +339,13 @@ public function testRetries(): void 'response' => [ 'statusCode' => 404, 'reason' => 'Not Found', - 'body' => 'You are receiving this error either because your input OAuth2 scope name is ' . - "invalid or it refers to a newer scope that is outside the domain of this legacy API.\n\n" . - 'This API was built at a time when the scope name format was not yet standardized. This ' . - 'is no longer the case and all valid scope names (both old and new) are catalogued at ' . - 'https://developers.google.com/identity/protocols/oauth2/scopes. Use that webpage to' . - ' lookup (manually) the scope name associated with the API you are trying to call and use' . - " it to craft your OAuth2 request.\n", + 'body' => 'You are receiving this error either because your input OAuth2 scope name is ' + . "invalid or it refers to a newer scope that is outside the domain of this legacy API.\n\n" + . 'This API was built at a time when the scope name format was not yet standardized. ' + . 'This is no longer the case and all valid scope names (both old and new) are catalogued ' + . 'at https://developers.google.com/identity/protocols/oauth2/scopes. Use that webpage to' + . ' lookup (manually) the scope name associated with the API you are trying to call and use' + . " it to craft your OAuth2 request.\n", ], ], $value['context'], @@ -158,7 +358,7 @@ public function testDoNotRetryOnWrongCredentials(): void $testHandler = new TestHandler(); $logger = new Logger('Google Rest API tests'); $logger->pushHandler($testHandler); - $api = new RestApi( + $api = RestApi::createWithOAuth( $this->getEnv('CLIENT_ID'), $this->getEnv('CLIENT_SECRET') . 'invalid', '', @@ -189,4 +389,9 @@ protected function getEnv(string $name): string return $value; } + + protected function hasServiceAccountCredentials(): bool + { + return getenv('SERVICE_ACCOUNT_JSON') !== false; + } }