Skip to content
Closed
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
129 changes: 129 additions & 0 deletions src/PostProcessor/FirestoreRequestParamProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types=1);

namespace Google\PostProcessor;

use Microsoft\PhpParser\Node\MethodDeclaration;
use LogicException;
use ParseError;

class FirestoreRequestParamProcessor implements ProcessorInterface
{
use PostProcessorTrait;

// Line to insert the PHP doc param above
private const DATABASE_PHPDOC_INSERT_AT =
' * @type int $timeoutMillis';

// PHPdoc param to insert
private const DATABASE_PHPDOC_PARAM = <<<'EOL'
* @type string $database
* Set the database of the call, to be added as a routing header
EOL;

// Line to insert the request param code above
private const DATABASE_REQUEST_PARAM_INSERT_AT =
' return $this->startApiCall(\'Listen\', null, $callOptions);';

// Request param code to insert
private const DATABASE_REQUEST_PARAM_CODE = <<<'EOL'
$requestParamHeaders = [];
if (isset($callOptions['database'])) {
$requestParamHeaders['database'] = $callOptions['database'];
}
$requestParams = new \Google\ApiCore\RequestParamsHeaderDescriptor($requestParamHeaders);
$callOptions['headers'] = isset($callOptions['headers']) ? array_merge($requestParams->getHeader(), $callOptions['headers']) : $requestParams->getHeader();
EOL;

public static function run(string $inputDir): void
{
$firestoreClientFile = $inputDir . '/src/V1/Client/FirestoreClient.php';
if (file_exists($firestoreClientFile)) {
self::inject($firestoreClientFile);
}
}

private static function inject(string $classFile): void
{
// The class to update
$classContent = file_get_contents($classFile);
$processor = new FirestoreRequestParamProcessor($classContent);

$processor->addDatabaseRequestParamToListenMethod();

// Write the new contents to the class file.
file_put_contents($classFile, $processor->getContents());
print("Fragment written to $classFile\n");
}

/**
* @throws LogicException
* @throws ParseError
*/
public function addDatabaseRequestParamToListenMethod(): void
{
$contents = $this->classNode->getFileContents();
$listenMethod = $this->getMethodDeclaration('listen');

// update PHPDoc
$phpdoc = $listenMethod->getDocCommentText();
if (false === strpos($phpdoc, self::DATABASE_PHPDOC_PARAM)) {
$newLines = explode(PHP_EOL, self::DATABASE_PHPDOC_PARAM);
$newLines[] = self::DATABASE_PHPDOC_INSERT_AT;


$newPhpdoc = str_replace(self::DATABASE_PHPDOC_INSERT_AT, implode(PHP_EOL, $newLines), $phpdoc);
$contents = str_replace($phpdoc, $newPhpdoc, $contents);
}

// Update param code
$methodText = $listenMethod->compoundStatementOrSemicolon->getText();
// indent each line 8 spaces
$indent = str_repeat(' ', 8);
$newLines = array_map(
fn ($line) => $indent . $line,
explode(PHP_EOL, self::DATABASE_REQUEST_PARAM_CODE)
);
if (false === strpos($methodText, $newLines[0])) {
$newLines[] = self::DATABASE_REQUEST_PARAM_INSERT_AT;

$newMethodText = str_replace(
self::DATABASE_REQUEST_PARAM_INSERT_AT,
implode(PHP_EOL, $newLines), $methodText
);
$contents = str_replace($methodText, $newMethodText, $contents);
}

$this->classNode = self::fromCode($contents);
}

private function getMethodDeclaration(string $insertBeforeMethod): MethodDeclaration
{
foreach ($this->classNode->getDescendantNodes() as $childNode) {
if ($childNode instanceof MethodDeclaration) {
if ($childNode->getName() === $insertBeforeMethod) {
return $childNode;
}
}
}

throw new LogicException(
'Provided contents does not contain method ' . $insertBeforeMethod
);
}
}
32 changes: 1 addition & 31 deletions src/PostProcessor/FragmentInjectionProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,14 @@

namespace Google\PostProcessor;

use Microsoft\PhpParser\Node\Statement\ClassDeclaration;
use Microsoft\PhpParser\Node\MethodDeclaration;
use Microsoft\PhpParser\Parser;
use Microsoft\PhpParser\PositionUtilities;
use Microsoft\PhpParser\DiagnosticsProvider;
use LogicException;
use ParseError;

class FragmentInjectionProcessor implements ProcessorInterface
{
private ClassDeclaration $classNode;
use PostProcessorTrait;

public static function run(string $inputDir): void
{
Expand Down Expand Up @@ -62,28 +59,6 @@ private static function inject(string $fragmentFile, string $classFile): void
print("Fragment written to $classFile\n");
}

private static function fromCode(string $contents): ClassDeclaration
{
$parser = new Parser();
$astNode = $parser->parseSourceFile($contents);
if ($errors = DiagnosticsProvider::getDiagnostics($astNode)) {
throw new ParseError('Provided contents contains a PHP syntax error');
}

foreach ($astNode->getDescendantNodes() as $childNode) {
if ($childNode instanceof ClassDeclaration) {
return $childNode;
}
}

throw new LogicException('Provided contents does not contain a PHP class');
}

public function __construct(string $contents)
{
$this->classNode = self::fromCode($contents);
}

/**
* @throws LogicException
* @throws ParseError
Expand All @@ -100,11 +75,6 @@ public function insert(string $newContent, ?string $insertBeforeMethod = null):
$this->classNode = self::fromCode($contents);
}

public function getContents(): string
{
return $this->classNode->getFileContents();
}

private function getLineNumberFromPosition(int $startPosition): int
{
return PositionUtilities::getLineCharacterPositionFromPosition(
Expand Down
5 changes: 4 additions & 1 deletion src/PostProcessor/PostProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ private function execute(): void

private static function loadProcessors(array $opts): Vector
{
return Vector::new([FragmentInjectionProcessor::class]);
return Vector::new([
FragmentInjectionProcessor::class,
FirestoreRequestParamProcessor::class,
]);
}
}
59 changes: 59 additions & 0 deletions src/PostProcessor/PostProcessorTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types=1);

namespace Google\PostProcessor;

use Microsoft\PhpParser\Node\Statement\ClassDeclaration;
use Microsoft\PhpParser\Parser;
use Microsoft\PhpParser\DiagnosticsProvider;
use LogicException;
use ParseError;

trait PostProcessorTrait
{
private ClassDeclaration $classNode;

public function __construct(string $contents)
{
$this->classNode = self::fromCode($contents);
}

public function getContents(): string
{
return $this->classNode->getFileContents();
}

public static abstract function run(string $inputDir): void;

private static function fromCode(string $contents): ClassDeclaration
{
$parser = new Parser();
$astNode = $parser->parseSourceFile($contents);
if ($errors = DiagnosticsProvider::getDiagnostics($astNode)) {
throw new ParseError('Provided contents contains a PHP syntax error');
}

foreach ($astNode->getDescendantNodes() as $childNode) {
if ($childNode instanceof ClassDeclaration) {
return $childNode;
}
}

throw new LogicException('Provided contents does not contain a PHP class');
}
}
103 changes: 103 additions & 0 deletions tests/Unit/PostProcessor/FirestoreRequestParamProcessorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types=1);

namespace Google\Generator\Tests\Unit\PostProcessor;

use PHPUnit\Framework\TestCase;
use Google\PostProcessor\FirestoreRequestParamProcessor;
use ParseError;

final class FirestoreRequestParamProcessorTest extends TestCase
{
private static $classContents = <<<EOL
<?php
namespace Google\Cloud\Firestore\V1\Client;

final class FirestoreClient
{
use GapicClientTrait;

/** The name of the service. */
private const SERVICE_NAME = 'google.firestore.v1.Firestore';

private static function getClientDefaults()
{
return [
'serviceName' => self::SERVICE_NAME,
];
}

public function __construct(array \$options = [])
{
\$clientOptions = \$this->buildClientOptions(\$options);
\$this->setClientOptions(\$clientOptions);
}

/**
* Listens to changes. This method is only available via gRPC or WebChannel
* (not REST).
*
* @example samples/V1/FirestoreClient/listen.php
*
* @param array \$callOptions {
* Optional.
*
* @type int \$timeoutMillis
* Timeout to use for this call.
* }
*
* @return BidiStream
*
* @throws ApiException Thrown if the API call fails.
*/
public function listen(array \$callOptions = []): BidiStream
{
return \$this->startApiCall('Listen', null, \$callOptions);
}
}
EOL;

public function testFirestoreRequestParamProcessor()
{
$firestorePostProcessor = new FirestoreRequestParamProcessor(self::$classContents);

// Insert the function before this one
$firestorePostProcessor->addDatabaseRequestParamToListenMethod();
$newClassContents = $firestorePostProcessor->getContents();

$this->assertStringContainsString('@type string $database', $newClassContents);
$this->assertStringContainsString('new \Google\ApiCore\RequestParamsHeaderDescriptor', $newClassContents);

// Insert again and ensure it's not added twice
$firestorePostProcessor->addDatabaseRequestParamToListenMethod();
$newClassContents = $firestorePostProcessor->getContents();
$this->assertEquals(1, substr_count($newClassContents, '@type string $database'));
$this->assertEquals(1, substr_count($newClassContents, 'new \Google\ApiCore\RequestParamsHeaderDescriptor'));
}

public function testFirestoreRequestParamDoesNotContainSyntaxErrors()
{
$firestorePostProcessor = new FirestoreRequestParamProcessor(self::$classContents);

$codeString = $firestorePostProcessor->getContents();

// This will throw a ParseError if the syntax is invalid
$tokens = token_get_all("<?php " . $codeString);
$this->assertNotNull($tokens);
}
}