diff --git a/src/AcsfApi/AcsfConnectorFactory.php b/src/AcsfApi/AcsfConnectorFactory.php index 186ec5f20..86792799d 100644 --- a/src/AcsfApi/AcsfConnectorFactory.php +++ b/src/AcsfApi/AcsfConnectorFactory.php @@ -5,6 +5,7 @@ namespace Acquia\Cli\AcsfApi; use Acquia\Cli\ConnectorFactoryInterface; +use AcquiaCloudApi\Connector\ConnectorInterface; class AcsfConnectorFactory implements ConnectorFactoryInterface { @@ -15,7 +16,7 @@ public function __construct(protected array $config, protected ?string $baseUri { } - public function createConnector(): AcsfConnector + public function createConnector(): ConnectorInterface { return new AcsfConnector($this->config, $this->baseUri); } diff --git a/src/CloudApi/ConnectorFactory.php b/src/CloudApi/ConnectorFactory.php index 5e1c99e73..74cd33fe5 100644 --- a/src/CloudApi/ConnectorFactory.php +++ b/src/CloudApi/ConnectorFactory.php @@ -6,6 +6,7 @@ use Acquia\Cli\ConnectorFactoryInterface; use AcquiaCloudApi\Connector\Connector; +use AcquiaCloudApi\Connector\ConnectorInterface; use League\OAuth2\Client\Token\AccessToken; class ConnectorFactory implements ConnectorFactoryInterface @@ -17,10 +18,22 @@ public function __construct(protected array $config, protected ?string $baseUri { } - /** - * @return \Acquia\Cli\CloudApi\AccessTokenConnector|\AcquiaCloudApi\Connector\Connector - */ - public function createConnector(): Connector|AccessTokenConnector + public function createConnector(): ConnectorInterface + { + $connector = $this->buildConnector(); + + // If the AH_CODEBASE_UUID environment variable is set, that means + // it's a MEO subscription. For MEO, we need to rewrite the API request + // path so that MEO-specific endpoints are used and the correct + // endpoint can be selected based on the codebase. + if (getenv('AH_CODEBASE_UUID')) { + return new PathRewriteConnector($connector); + } + + return $connector; + } + + private function buildConnector(): ConnectorInterface { // A defined key & secret takes priority. if ($this->config['key'] && $this->config['secret']) { diff --git a/src/CloudApi/PathRewriteConnector.php b/src/CloudApi/PathRewriteConnector.php new file mode 100644 index 000000000..517789885 --- /dev/null +++ b/src/CloudApi/PathRewriteConnector.php @@ -0,0 +1,131 @@ +inner = $inner; + } + + /** + * Creates a PSR-7 request, rewriting the path if it matches a rewrite rule. + * + * @param string $verb HTTP method (e.g., 'GET', 'POST'). + * @param string $path The original API path. + * @return RequestInterface The PSR-7 request with possibly rewritten path. + */ + public function createRequest(string $verb, string $path): RequestInterface + { + return $this->inner->createRequest($verb, $this->rewritePath($path)); + } + + /** + * Sends an HTTP request, rewriting the path if it matches a rewrite rule. + * + * @param string $verb HTTP method (e.g., 'GET', 'POST'). + * @param string $path The original API path. + * @param array $options Additional request options. + * @return ResponseInterface The HTTP response. + */ + public function sendRequest(string $verb, string $path, array $options): ResponseInterface + { + return $this->inner->sendRequest($verb, $this->rewritePath($path), $options); + } + + /** + * Returns the base URI for the API. + * + * @return string The base URI. + */ + public function getBaseUri(): string + { + return $this->inner->getBaseUri(); + } + + /** + * Returns the access token for URL authentication. + * + * @return string The access token. + */ + public function getUrlAccessToken(): string + { + return $this->inner->getUrlAccessToken(); + } + + /** + * Rewrites the API path using preg_replace if it matches any rewrite rule. + * + * @param string $path The original API path. + * @return string The rewritten path, or the original if no rule matches. + */ + private function rewritePath(string $path): string + { + foreach ($this->getPathsToRewrite() as $pattern => $replacement) { + if (preg_match($pattern, $path) === 1) { + return (string) preg_replace($pattern, $replacement, $path); + } + } + + // Return the original path if no rewrite rule matches. + return $path; + } + + /** + * Returns an array of regex patterns and their corresponding replacement paths for rewriting API request paths. + * + * Two rules cover all cases: + * - Paths with a trailing segment: /applications/{uuid}/foo/bar → /translation/codebases/{codebaseUuid}/foo/bar + * - Bare application UUID paths: /applications/{uuid} → /translation/codebases/{codebaseUuid} + * + * The first rule uses a capture group ($1) so any trailing path is preserved automatically, + * avoiding the need to enumerate every possible sub-path. + * + * @return array Regex pattern => preg_replace replacement string. + */ + private function getPathsToRewrite(): array + { + $codebaseUuid = $this->getCodeBaseUuid(); + return [ + // Matches bare /applications/{uuid} with no trailing segment. + '#^/applications/[0-9a-f\-]+$#i' => '/translation/codebases/' . $codebaseUuid, + // Matches /applications/{uuid}/{anything} and preserves the trailing segment via $1. + '#^/applications/[0-9a-f\-]+/(.+)$#i' => '/translation/codebases/' . $codebaseUuid . '/$1', + ]; + } + + /** + * Retrieves the codebase UUID. + */ + private function getCodeBaseUuid(): string + { + $codebaseUuid = getenv('AH_CODEBASE_UUID'); + if (!$codebaseUuid) { + throw new \RuntimeException('Environment variable AH_CODEBASE_UUID is not set.'); + } + return $codebaseUuid; + } +} diff --git a/src/ConnectorFactoryInterface.php b/src/ConnectorFactoryInterface.php index ded5c0138..97c9b5a11 100644 --- a/src/ConnectorFactoryInterface.php +++ b/src/ConnectorFactoryInterface.php @@ -4,10 +4,9 @@ namespace Acquia\Cli; -use Acquia\Cli\CloudApi\AccessTokenConnector; -use AcquiaCloudApi\Connector\Connector; +use AcquiaCloudApi\Connector\ConnectorInterface; interface ConnectorFactoryInterface { - public function createConnector(): Connector|AccessTokenConnector; + public function createConnector(): ConnectorInterface; } diff --git a/tests/phpunit/src/CloudApi/ConnectorFactoryTest.php b/tests/phpunit/src/CloudApi/ConnectorFactoryTest.php new file mode 100644 index 000000000..43c9e521e --- /dev/null +++ b/tests/phpunit/src/CloudApi/ConnectorFactoryTest.php @@ -0,0 +1,77 @@ +originalEnv = getenv('AH_CODEBASE_UUID'); + } + + + /** + * @dataProvider connectorFactoryProvider + */ + public function testCreateConnectorFactoryBehavior(?string $envValue, string $expectedClass): void + { + if ($envValue !== null) { + putenv("AH_CODEBASE_UUID=$envValue"); + } else { + putenv('AH_CODEBASE_UUID'); + } + $factory = new ConnectorFactory(['key' => 'k', 'secret' => 's'], 'https://api.example.com'); + $connector = $factory->createConnector(); + $this->assertInstanceOf($expectedClass, $connector); + } + + /** + * Data provider for testCreateConnectorFactoryBehavior() test. + * + * @return array + */ + public static function connectorFactoryProvider(): array + { + return [ + // Env set: should return PathRewriteConnector. + ['1234-5678-uuid', PathRewriteConnector::class], + // Env not set: should return Connector. + [null, Connector::class], + ]; + } + + /** + * Restores the original environment variable after each test. + */ + protected function tearDown(): void + { + if ($this->originalEnv === false) { + putenv('AH_CODEBASE_UUID'); + } else { + putenv('AH_CODEBASE_UUID=' . $this->originalEnv); + } + parent::tearDown(); + } +} diff --git a/tests/phpunit/src/CloudApi/PathRewriteConnectorTest.php b/tests/phpunit/src/CloudApi/PathRewriteConnectorTest.php new file mode 100644 index 000000000..6cd0957de --- /dev/null +++ b/tests/phpunit/src/CloudApi/PathRewriteConnectorTest.php @@ -0,0 +1,224 @@ +originalEnv = getenv('AH_CODEBASE_UUID'); + putenv('AH_CODEBASE_UUID=1234-5678-uuid'); + $this->inner = $this->createMock(ConnectorInterface::class); + $this->connector = new PathRewriteConnector($this->inner); + } + + /** + * @dataProvider createRequestProvider + * @param string $verb The HTTP verb to test. + * @param string $inputPath The input path to test. + * @param string $expectedPath The expected path after rewriting. + */ + public function testCreateRequestPathRewriting(string $verb, string $inputPath, string $expectedPath): void + { + $mock = $this->createMock(RequestInterface::class); + $this->inner->expects($this->once()) + ->method('createRequest') + ->with($verb, $expectedPath) + ->willReturn($mock); + $result = $this->connector->createRequest($verb, $inputPath); + $this->assertSame($mock, $result); + } + + + + /** + * @dataProvider sendRequestProvider + * @param string $verb The HTTP verb to test. + * @param string $inputPath The input path to test. + * @param string $expectedPath The expected path after rewriting. + * @param array $options The options to pass to sendRequest. + */ + public function testSendRequestPathRewriting(string $verb, string $inputPath, string $expectedPath, array $options): void + { + $mock = $this->createMock(ResponseInterface::class); + $this->inner->expects($this->once()) + ->method('sendRequest') + ->with($verb, $expectedPath, $options) + ->willReturn($mock); + $result = $this->connector->sendRequest($verb, $inputPath, $options); + $this->assertSame($mock, $result); + } + + /** + * @dataProvider delegationProvider + * @param string $method The method to test delegation for. + * @param mixed $expected The expected return value from the inner connector. + */ + public function testDelegation(string $method, string $expected): void + { + $this->inner->expects($this->once()) + ->method($method) + ->willReturn($expected); + $this->assertTrue(method_exists($this->connector, $method)); + $this->assertSame($expected, $this->connector->{$method}()); + } + + /** + * Ensures an exception is thrown if AH_CODEBASE_UUID is not set when required. + */ + public function testThrowsIfCodebaseUuidNotSet(): void + { + putenv('AH_CODEBASE_UUID'); + $connector = new PathRewriteConnector($this->inner); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Environment variable AH_CODEBASE_UUID is not set.'); + // This will trigger getCodeBaseUuid() + $connector->createRequest('GET', '/applications/abcd-ef01/environments'); + } + + /** + * Data provider for createRequest tests. Ensures that paths are rewritten + * correctly based on the presence of the code base environment variable. + * + * @return array + */ + public static function createRequestProvider(): array + { + return [ + 'account path is not rewritten' => [ + 'GET', + '/account', + '/account', + ], + // Bare UUID (no trailing segment) is also rewritten. + 'bare application UUID is rewritten' => [ + 'GET', + '/applications/abcd-ef01', + '/translation/codebases/1234-5678-uuid', + ], + // Deep sub-path: entire trailing part is preserved by $1. + 'deep sub-path is rewritten' => ['GET', + '/applications/abcd-ef01/environments/env-1/tags', + '/translation/codebases/1234-5678-uuid/environments/env-1/tags', + ], + // Single trailing segment is rewritten via capture group ($1). + 'environments path is rewritten' => [ + 'GET', + '/applications/abcd-ef01/environments', + '/translation/codebases/1234-5678-uuid/environments', + ], + 'permissions path is rewritten' => [ + 'GET', + '/applications/abcd-ef01/permissions', + '/translation/codebases/1234-5678-uuid/permissions', + ], + // Paths that do not start with /applications/{uuid} are left unchanged. + 'unrelated path is not rewritten' => [ + 'GET', + '/other/path', + '/other/path', + ], + ]; + } + + /** + * Data provider for sendRequest tests. Ensures that both path rewriting + * and options are handled correctly. + * + * @return array}> + */ + public static function sendRequestProvider(): array + { + return [ + // Bare UUID (no trailing segment) is also rewritten. + 'bare application UUID is rewritten' => [ + 'GET', + '/applications/abcd-ef01', + '/translation/codebases/1234-5678-uuid', + [], + ], + // Deep sub-path: entire trailing part is preserved by $1. + 'deep sub-path is rewritten' => [ + 'POST', + '/applications/abcd-ef01/environments/env-1/tags', + '/translation/codebases/1234-5678-uuid/environments/env-1/tags', + [], + ], + // Single trailing segment is rewritten via capture group ($1). + 'permissions path is rewritten' => [ + 'POST', + '/applications/abcd-ef01/permissions', + '/translation/codebases/1234-5678-uuid/permissions', + ['foo' => 'bar'], + ], + // Paths that do not start with /applications/{uuid} are left unchanged. + 'unrelated path is not rewritten' => [ + 'GET', + '/other/path', + '/other/path', + [], + ], + ]; + } + + /** + * Data provider for delegation tests. Ensures that methods not related to + * path rewriting are properly delegated to the inner connector. + * + * @return array + */ + public static function delegationProvider(): array + { + return [ + ['getBaseUri', 'https://api.example.com'], + ['getUrlAccessToken', 'token123'], + ]; + } + + /** + * Restores the original AH_CODEBASE_UUID environment variable after each test. + */ + protected function tearDown(): void + { + if ($this->originalEnv === false) { + putenv('AH_CODEBASE_UUID'); + } else { + putenv('AH_CODEBASE_UUID=' . $this->originalEnv); + } + parent::tearDown(); + } +}