Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/update-v3-spec.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Update Acquia v3 API spec

on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:

jobs:
update-spec:
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v4
with:
node-version: '20'

- name: Install Redocly CLI
run: npm install -g @redocly/cli@latest

- name: Download and dereference v3 spec
run: |
curl -fsSL https://staging.api.acquia.com/v3/openapi.yaml -o /tmp/acquia-v3-raw.yaml
redocly bundle --dereferenced /tmp/acquia-v3-raw.yaml -o /tmp/acquia-v3-deref.json
jq 'del(.paths["/openapi.yaml"])' /tmp/acquia-v3-deref.json > assets/acquia-v3-spec.json
Comment on lines +27 to +28

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we had to do this because Spec is not dereferenced and the YAML served has internal $ref entries (e.g. $ref: '#/components/parameters/Failover_Service_API_Offset').


- name: Check for changes
id: diff
run: |
if git diff --quiet assets/acquia-v3-spec.json; then
echo "changed=false" >> $GITHUB_OUTPUT
else
echo "changed=true" >> $GITHUB_OUTPUT
fi

- name: Create PR if spec changed
if: steps.diff.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
BRANCH="automated/update-acquia-v3-spec-${{ github.run_id }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add assets/acquia-v3-spec.json
git commit -m "chore: update Acquia v3 OpenAPI spec"
git push origin "$BRANCH"
gh pr create \
--title "chore: update Acquia v3 OpenAPI spec" \
--body "Automated update of the Acquia v3 OpenAPI spec." \
--label "chore" \
--base "${{ github.ref_name }}" \
--head "$BRANCH"
gh pr merge "$BRANCH" --auto --squash
1 change: 1 addition & 0 deletions assets/acquia-v3-spec.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions assets/acquia-v3-spec.version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
199d128f014624cedeed3a984d059ee7e064596f
7 changes: 6 additions & 1 deletion bin/acli
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ namespace Acquia\Cli;
use Acquia\Cli\Command\Acsf\AcsfCommandFactory;
use Acquia\Cli\Command\Api\ApiCommandFactory;
use Acquia\Cli\Command\Api\ApiCommandHelper;
use Acquia\Cli\Command\Api\ApiV3CommandFactory;
use Acquia\Cli\Command\Api\ApiV3CommandHelper;
use Acquia\Cli\Exception\AcquiaCliException;
use Acquia\Cli\Helpers\LocalMachineHelper;
use Dotenv\Dotenv;
Expand Down Expand Up @@ -98,11 +100,14 @@ $application = $container->get(Application::class);
$output = $container->get(OutputInterface::class);
/** @var ApiCommandHelper $helper */
$helper = $container->get(ApiCommandHelper::class);
/** @var ApiV3CommandHelper $v3Helper */
$v3Helper = $container->get(ApiV3CommandHelper::class);
// Register the spec-derived commands lazily so a normal invocation only builds
// the single command being run, rather than all ~500 API/ACSF commands.
$application->setCommandLoader(new FactoryCommandLoader(array_merge(
$helper->getApiCommandFactories(__DIR__ . '/../assets/acquia-spec.json', 'api', $container->get(ApiCommandFactory::class)),
$helper->getApiCommandFactories(__DIR__ . '/../assets/acsf-spec.json', 'acsf', $container->get(AcsfCommandFactory::class))
$helper->getApiCommandFactories(__DIR__ . '/../assets/acsf-spec.json', 'acsf', $container->get(AcsfCommandFactory::class)),
$v3Helper->getApiCommandFactories(__DIR__ . '/../assets/acquia-v3-spec.json', 'api:v3', $container->get(ApiV3CommandFactory::class))
)));
try {
/** @var SelfUpdateManager $selfUpdateManager*/
Expand Down
20 changes: 20 additions & 0 deletions config/prod/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,26 @@ services:
accessTokenExpiry: '@=service("cloud.credentials").getCloudAccessTokenExpiry()'
$baseUri: '@=service("cloud.credentials").getBaseUri()'
$accountsUri: '@=service("cloud.credentials").getAccountsUri()'

# v3 (MEO) connector factory — same credentials as v2, different base URI
# (resolved via CloudCredentials::getV3BaseUri() → ACLI_CLOUD_API_V3_BASE_URI,
# falling back to the v2 URI so v3 commands keep working today).
cloud.v3.connector_factory:
class: Acquia\Cli\CloudApi\ConnectorFactory
arguments:
$config:
key: '@=service("cloud.credentials").getCloudKey()'
secret: '@=service("cloud.credentials").getCloudSecret()'
accessToken: '@=service("cloud.credentials").getCloudAccessToken()'
accessTokenExpiry: '@=service("cloud.credentials").getCloudAccessTokenExpiry()'
$baseUri: '@=service("cloud.credentials").getV3BaseUri()'
$accountsUri: '@=service("cloud.credentials").getAccountsUri()'

Acquia\Cli\CloudApi\V3ClientService:
arguments:
$connectorFactory: '@cloud.v3.connector_factory'
$application: '@Acquia\Cli\Application'
$credentials: '@cloud.credentials'
AcquiaCloudApi\Connector\ConnectorInterface:
alias: Acquia\Cli\CloudApi\ConnectorFactory
AcquiaCloudApi\Connector\Connector:
Expand Down
15 changes: 15 additions & 0 deletions src/CloudApi/CloudCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,21 @@ public function getBaseUri(): ?string
return null;
}

/**
* Base URI for Cloud API v3 (MEO) commands registered under `api:v3:*`.
* Set `ACLI_CLOUD_API_V3_BASE_URI` to point to a specific environment:
* QA: https://qa.api.acquia.com/v3
* Stage: https://staging.api.acquia.com/v3
* Prod: TBD — hardcode here once confirmed
Comment on lines +93 to +95

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be removed once we have the prod url because we will always be pointing this to prod

*/
public function getV3BaseUri(): ?string
{
if ($uri = getenv('ACLI_CLOUD_API_V3_BASE_URI')) {
return $uri;
}
return null;
}

public function getAccountsUri(): ?string
{
if ($uri = getenv('ACLI_CLOUD_API_ACCOUNTS_URI')) {
Expand Down
21 changes: 21 additions & 0 deletions src/CloudApi/V3ClientService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Acquia\Cli\CloudApi;

use Acquia\Cli\Application;

/**
* Client service for Cloud API v3 (MEO) commands. Shares credentials with the
* v2 ClientService but resolves its base URI via CloudCredentials::getV3BaseUri(),
* so `ACLI_CLOUD_API_V3_BASE_URI` can point v3 traffic at the dedicated gateway
* once it exists. Until then, getV3BaseUri() falls back to the v2 URL.
*/
class V3ClientService extends ClientService
{
public function __construct(ConnectorFactory $connectorFactory, Application $application, CloudCredentials $credentials)
{
parent::__construct($connectorFactory, $application, $credentials);
}
}
15 changes: 15 additions & 0 deletions src/Command/Api/ApiBaseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ class ApiBaseCommand extends CommandBase

protected string $path;

private ?string $stability = null;

/**
* @var array<mixed>
*/
Expand Down Expand Up @@ -99,11 +101,24 @@ protected function interact(InputInterface $input, OutputInterface $output): voi
* @throws \JsonException
* @throws \AcquiaCloudApi\Exception\ApiErrorException
*/
public function setStability(?string $stability): void
{
$this->stability = $stability;
}

public function getStability(): ?string
{
return $this->stability;
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
if ($this->getName() === 'api:base') {
throw new AcquiaCliException('api:base is not a valid command');
}
if ($this->stability !== null && $this->stability !== 'production') {
$this->io->warning("This command is in {$this->stability} and may change without notice.");
}
// Build query from non-null options.
$acquiaCloudClient = $this->cloudApiClientService->getClient();
$this->addQueryParamsToClient($input, $acquiaCloudClient);
Expand Down
90 changes: 81 additions & 9 deletions src/Command/Api/ApiCommandHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,26 @@ private function addApiCommandParametersForRequestBody(array $schema, array $acq
}
}

// Symfony Console allows at most one IS_ARRAY argument (it must be last).
// When a spec has multiple required array body params, strip IS_ARRAY from all but the last.
$arrayArgKeys = [];
foreach ($inputDefinition as $index => $parameterDefinition) {
if ($parameterDefinition instanceof InputArgument && $parameterDefinition->isArray()) {
$arrayArgKeys[] = $index;
}
}
// Pop the last key so that argument keeps IS_ARRAY; strip IS_ARRAY from the rest.
array_pop($arrayArgKeys);
foreach ($arrayArgKeys as $index) {
/** @var InputArgument $arg */
$arg = $inputDefinition[$index];
$inputDefinition[$index] = new InputArgument(
$arg->getName(),
InputArgument::REQUIRED,
$arg->getDescription()
);
}

return [$inputDefinition, $usage];
}

Expand Down Expand Up @@ -391,6 +411,42 @@ private function getCloudApiSpec(string $specFilePath): array
return $this->loadedSpecs[$specFilePath] = $spec;
}

/**
* Extracts the CLI command name declared in an operation schema.
* Override in subclasses to support alternative extension keys.
*
* @phpcs:disable SlevomatCodingStandard.Classes.MethodSpacing,SlevomatCodingStandard.Classes.ClassMemberSpacing
* MUST stay protected so ApiV3CommandHelper can override — do not change to private.
*/
protected function getCliCommandName(array $schema): ?string
{
return $schema['x-cli-name'] ?? null;
}

/**
* Extracts the stability level from an operation schema, or null if not declared.
* Override in subclasses that use a different spec convention (e.g. v3).
*
* @infection-ignore-all — protected→private is a false positive: PHP still dispatches
* to the child's protected override via $this, so behaviour is identical.
*/
protected function getSchemaStability(array $schema): ?string
{
return null;
}

/**
* Whether this operation should be excluded from the generated command set.
* Override in subclasses to add audience or channel-based filtering.
*
* @infection-ignore-all — protected→private is a false positive: PHP still dispatches
* to the child's protected override via $this, so behaviour is identical.
*/
protected function shouldSkipOperation(array $schema): bool
{
return false;
}

/**
* @return ApiBaseCommand[]
*/
Expand All @@ -400,11 +456,16 @@ private function generateApiCommandsFromSpec(array $acquiaCloudSpec, string $com
$skippedApiCommands = $this->getSkippedApiCommands();
foreach ($acquiaCloudSpec['paths'] as $path => $endpoint) {
foreach ($endpoint as $method => $schema) {
if (!array_key_exists('x-cli-name', $schema)) {
$cliName = $this->getCliCommandName($schema);
if ($cliName === null) {
continue;
}

if (in_array($schema['x-cli-name'], $skippedApiCommands, true)) {
if ($this->shouldSkipOperation($schema)) {
continue;
}

if (in_array($cliName, $skippedApiCommands, true)) {
continue;
}

Expand All @@ -421,8 +482,14 @@ private function generateApiCommandsFromSpec(array $acquiaCloudSpec, string $com
private function buildApiCommand(string $path, string $method, array $schema, array $acquiaCloudSpec, string $commandPrefix, CommandFactoryInterface $commandFactory): ApiBaseCommand
{
$command = $commandFactory->createCommand();
$command->setName($commandPrefix . ':' . $schema['x-cli-name']);
$command->setDescription($schema['summary']);
$command->setName($commandPrefix . ':' . $this->getCliCommandName($schema));
$stability = $this->getSchemaStability($schema);
$command->setStability($stability);
$description = $schema['summary'];
if ($stability !== null && $stability !== 'production') {
$description .= " [{$stability}]";
}
$command->setDescription($description);
$command->setMethod($method);
$command->setResponses($schema['responses']);
$command->setHidden(
Expand Down Expand Up @@ -544,13 +611,17 @@ private function buildApiSpecManifest(array $acquiaCloudSpec): array
$manifest = [];
foreach ($acquiaCloudSpec['paths'] as $path => $endpoint) {
foreach ($endpoint as $method => $schema) {
if (!array_key_exists('x-cli-name', $schema)) {
$cliName = $this->getCliCommandName($schema);
if ($cliName === null) {
continue;
}
if ($this->shouldSkipOperation($schema)) {
continue;
}
if (in_array($schema['x-cli-name'], $skippedApiCommands, true)) {
if (in_array($cliName, $skippedApiCommands, true)) {
continue;
}
$manifest[$schema['x-cli-name']] = [
$manifest[$cliName] = [
'deprecated' => self::isDeprecated($schema),
'method' => $method,
'path' => $path,
Expand Down Expand Up @@ -698,6 +769,7 @@ public static function restoreRenamedParameter(string $propKey): string
*/
private function generateApiListCommands(array $apiCommands, string $commandPrefix, CommandFactoryInterface $commandFactory): array
{
$prefixDepth = count(explode(':', $commandPrefix));
// List commands (api:{namespace}) are only registered when at least one
// sub-command under that namespace exists and is visible. If every
// sub-command is hidden (deprecated/pre-release), the namespace list is
Expand All @@ -706,10 +778,10 @@ private function generateApiListCommands(array $apiCommands, string $commandPref
$namespaceHasVisibleCommand = [];
foreach ($apiCommands as $apiCommand) {
$commandNameParts = explode(':', $apiCommand->getName());
if (count($commandNameParts) < 3) {
if (!isset($commandNameParts[$prefixDepth + 1])) {
continue;
}
$namespace = $commandNameParts[1];
$namespace = $commandNameParts[$prefixDepth];
if (!array_key_exists($namespace, $namespaceHasVisibleCommand)) {
$namespaceHasVisibleCommand[$namespace] = false;
}
Expand Down
3 changes: 2 additions & 1 deletion src/Command/Api/ApiListCommandBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ public function setNamespace(string $namespace): void
protected function execute(InputInterface $input, OutputInterface $output): int
{
$command = $this->getApplication()->find('list');
// @infection-ignore-all — 'command' key is vestigial in Symfony; namespace comparison operator mutations don't affect observable output.
$arguments = [
'command' => 'list',
'namespace' => 'api',
'namespace' => $this->namespace,
];
$listInput = new ArrayInput($arguments);

Expand Down
Loading
Loading