Skip to content

Commit df40920

Browse files
authored
IDE-4857: Add PathRewriteConnector for environment based API path rewriting to make commands work with MEO. (#1977)
* Fixed the error for some commands that doesn't work in MEO. * IDE-4857: Fixed the co-polit suggestions. * IDE-4857: Use regex to search and replace path instead of hardcoding paths.
1 parent 37e99ec commit df40920

6 files changed

Lines changed: 453 additions & 8 deletions

File tree

src/AcsfApi/AcsfConnectorFactory.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Acquia\Cli\AcsfApi;
66

77
use Acquia\Cli\ConnectorFactoryInterface;
8+
use AcquiaCloudApi\Connector\ConnectorInterface;
89

910
class AcsfConnectorFactory implements ConnectorFactoryInterface
1011
{
@@ -15,7 +16,7 @@ public function __construct(protected array $config, protected ?string $baseUri
1516
{
1617
}
1718

18-
public function createConnector(): AcsfConnector
19+
public function createConnector(): ConnectorInterface
1920
{
2021
return new AcsfConnector($this->config, $this->baseUri);
2122
}

src/CloudApi/ConnectorFactory.php

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Acquia\Cli\ConnectorFactoryInterface;
88
use AcquiaCloudApi\Connector\Connector;
9+
use AcquiaCloudApi\Connector\ConnectorInterface;
910
use League\OAuth2\Client\Token\AccessToken;
1011

1112
class ConnectorFactory implements ConnectorFactoryInterface
@@ -17,10 +18,22 @@ public function __construct(protected array $config, protected ?string $baseUri
1718
{
1819
}
1920

20-
/**
21-
* @return \Acquia\Cli\CloudApi\AccessTokenConnector|\AcquiaCloudApi\Connector\Connector
22-
*/
23-
public function createConnector(): Connector|AccessTokenConnector
21+
public function createConnector(): ConnectorInterface
22+
{
23+
$connector = $this->buildConnector();
24+
25+
// If the AH_CODEBASE_UUID environment variable is set, that means
26+
// it's a MEO subscription. For MEO, we need to rewrite the API request
27+
// path so that MEO-specific endpoints are used and the correct
28+
// endpoint can be selected based on the codebase.
29+
if (getenv('AH_CODEBASE_UUID')) {
30+
return new PathRewriteConnector($connector);
31+
}
32+
33+
return $connector;
34+
}
35+
36+
private function buildConnector(): ConnectorInterface
2437
{
2538
// A defined key & secret takes priority.
2639
if ($this->config['key'] && $this->config['secret']) {
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Acquia\Cli\CloudApi;
6+
7+
use AcquiaCloudApi\Connector\ConnectorInterface;
8+
use Psr\Http\Message\RequestInterface;
9+
use Psr\Http\Message\ResponseInterface;
10+
11+
/**
12+
* Decorates a ConnectorInterface to rewrite specific API paths before sending
13+
* requests. Useful for redirecting legacy or alternative API endpoints to new
14+
* ones transparently.
15+
*/
16+
final class PathRewriteConnector implements ConnectorInterface
17+
{
18+
/**
19+
* The underlying connector to which requests are delegated after path rewriting.
20+
*/
21+
private ConnectorInterface $inner;
22+
23+
/**
24+
* PathRewriteConnector constructor.
25+
*
26+
* @param ConnectorInterface $inner The connector to decorate.
27+
*/
28+
public function __construct(
29+
ConnectorInterface $inner,
30+
) {
31+
$this->inner = $inner;
32+
}
33+
34+
/**
35+
* Creates a PSR-7 request, rewriting the path if it matches a rewrite rule.
36+
*
37+
* @param string $verb HTTP method (e.g., 'GET', 'POST').
38+
* @param string $path The original API path.
39+
* @return RequestInterface The PSR-7 request with possibly rewritten path.
40+
*/
41+
public function createRequest(string $verb, string $path): RequestInterface
42+
{
43+
return $this->inner->createRequest($verb, $this->rewritePath($path));
44+
}
45+
46+
/**
47+
* Sends an HTTP request, rewriting the path if it matches a rewrite rule.
48+
*
49+
* @param string $verb HTTP method (e.g., 'GET', 'POST').
50+
* @param string $path The original API path.
51+
* @param array<string, mixed> $options Additional request options.
52+
* @return ResponseInterface The HTTP response.
53+
*/
54+
public function sendRequest(string $verb, string $path, array $options): ResponseInterface
55+
{
56+
return $this->inner->sendRequest($verb, $this->rewritePath($path), $options);
57+
}
58+
59+
/**
60+
* Returns the base URI for the API.
61+
*
62+
* @return string The base URI.
63+
*/
64+
public function getBaseUri(): string
65+
{
66+
return $this->inner->getBaseUri();
67+
}
68+
69+
/**
70+
* Returns the access token for URL authentication.
71+
*
72+
* @return string The access token.
73+
*/
74+
public function getUrlAccessToken(): string
75+
{
76+
return $this->inner->getUrlAccessToken();
77+
}
78+
79+
/**
80+
* Rewrites the API path using preg_replace if it matches any rewrite rule.
81+
*
82+
* @param string $path The original API path.
83+
* @return string The rewritten path, or the original if no rule matches.
84+
*/
85+
private function rewritePath(string $path): string
86+
{
87+
foreach ($this->getPathsToRewrite() as $pattern => $replacement) {
88+
if (preg_match($pattern, $path) === 1) {
89+
return (string) preg_replace($pattern, $replacement, $path);
90+
}
91+
}
92+
93+
// Return the original path if no rewrite rule matches.
94+
return $path;
95+
}
96+
97+
/**
98+
* Returns an array of regex patterns and their corresponding replacement paths for rewriting API request paths.
99+
*
100+
* Two rules cover all cases:
101+
* - Paths with a trailing segment: /applications/{uuid}/foo/bar → /translation/codebases/{codebaseUuid}/foo/bar
102+
* - Bare application UUID paths: /applications/{uuid} → /translation/codebases/{codebaseUuid}
103+
*
104+
* The first rule uses a capture group ($1) so any trailing path is preserved automatically,
105+
* avoiding the need to enumerate every possible sub-path.
106+
*
107+
* @return array<string, string> Regex pattern => preg_replace replacement string.
108+
*/
109+
private function getPathsToRewrite(): array
110+
{
111+
$codebaseUuid = $this->getCodeBaseUuid();
112+
return [
113+
// Matches bare /applications/{uuid} with no trailing segment.
114+
'#^/applications/[0-9a-f\-]+$#i' => '/translation/codebases/' . $codebaseUuid,
115+
// Matches /applications/{uuid}/{anything} and preserves the trailing segment via $1.
116+
'#^/applications/[0-9a-f\-]+/(.+)$#i' => '/translation/codebases/' . $codebaseUuid . '/$1',
117+
];
118+
}
119+
120+
/**
121+
* Retrieves the codebase UUID.
122+
*/
123+
private function getCodeBaseUuid(): string
124+
{
125+
$codebaseUuid = getenv('AH_CODEBASE_UUID');
126+
if (!$codebaseUuid) {
127+
throw new \RuntimeException('Environment variable AH_CODEBASE_UUID is not set.');
128+
}
129+
return $codebaseUuid;
130+
}
131+
}

src/ConnectorFactoryInterface.php

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@
44

55
namespace Acquia\Cli;
66

7-
use Acquia\Cli\CloudApi\AccessTokenConnector;
8-
use AcquiaCloudApi\Connector\Connector;
7+
use AcquiaCloudApi\Connector\ConnectorInterface;
98

109
interface ConnectorFactoryInterface
1110
{
12-
public function createConnector(): Connector|AccessTokenConnector;
11+
public function createConnector(): ConnectorInterface;
1312
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Acquia\Cli\Tests\CloudApi;
6+
7+
use Acquia\Cli\CloudApi\ConnectorFactory;
8+
use Acquia\Cli\CloudApi\PathRewriteConnector;
9+
use AcquiaCloudApi\Connector\Connector;
10+
use PHPUnit\Framework\TestCase;
11+
12+
/**
13+
* @covers \Acquia\Cli\CloudApi\ConnectorFactory
14+
*
15+
* Unit tests for the ConnectorFactory. Ensures that the factory returns the correct
16+
* connector type depending on the presence of the AH_CODEBASE_UUID environment variable.
17+
*/
18+
class ConnectorFactoryTest extends TestCase
19+
{
20+
/**
21+
* Stores the original value of AH_CODEBASE_UUID to restore after each test.
22+
*/
23+
private string|false $originalEnv;
24+
25+
/**
26+
* Saves the original environment variable before each test.
27+
*/
28+
protected function setUp(): void
29+
{
30+
parent::setUp();
31+
$this->originalEnv = getenv('AH_CODEBASE_UUID');
32+
}
33+
34+
35+
/**
36+
* @dataProvider connectorFactoryProvider
37+
*/
38+
public function testCreateConnectorFactoryBehavior(?string $envValue, string $expectedClass): void
39+
{
40+
if ($envValue !== null) {
41+
putenv("AH_CODEBASE_UUID=$envValue");
42+
} else {
43+
putenv('AH_CODEBASE_UUID');
44+
}
45+
$factory = new ConnectorFactory(['key' => 'k', 'secret' => 's'], 'https://api.example.com');
46+
$connector = $factory->createConnector();
47+
$this->assertInstanceOf($expectedClass, $connector);
48+
}
49+
50+
/**
51+
* Data provider for testCreateConnectorFactoryBehavior() test.
52+
*
53+
* @return array<int, array{0: string|null, 1: class-string}>
54+
*/
55+
public static function connectorFactoryProvider(): array
56+
{
57+
return [
58+
// Env set: should return PathRewriteConnector.
59+
['1234-5678-uuid', PathRewriteConnector::class],
60+
// Env not set: should return Connector.
61+
[null, Connector::class],
62+
];
63+
}
64+
65+
/**
66+
* Restores the original environment variable after each test.
67+
*/
68+
protected function tearDown(): void
69+
{
70+
if ($this->originalEnv === false) {
71+
putenv('AH_CODEBASE_UUID');
72+
} else {
73+
putenv('AH_CODEBASE_UUID=' . $this->originalEnv);
74+
}
75+
parent::tearDown();
76+
}
77+
}

0 commit comments

Comments
 (0)