diff --git a/src/GapicClientTrait.php b/src/GapicClientTrait.php index f7df3c711..412fb93e1 100644 --- a/src/GapicClientTrait.php +++ b/src/GapicClientTrait.php @@ -43,6 +43,9 @@ use Google\ApiCore\Transport\GrpcTransport; use Google\ApiCore\Transport\RestTransport; use Google\ApiCore\Transport\TransportInterface; +use Google\ApiCore\Options\CallOptions; +use Google\ApiCore\Options\ClientOptions; +use Google\ApiCore\Options\TransportOptions; use Google\Auth\CredentialsLoader; use Google\Auth\FetchAuthTokenInterface; use Google\LongRunning\Operation; @@ -363,45 +366,50 @@ private function setClientOptions(array $options) 'libName', 'libVersion', ]); - - $clientConfig = $options['clientConfig']; - if (is_string($clientConfig)) { - $clientConfig = json_decode(file_get_contents($clientConfig), true); + if ($this->isNewClientSurface()) { + // cast to ClientOptions for new surfaces only + $options = new ClientOptions($options); + } elseif (is_string($options['clientConfig'])) { + // perform validation for V1 surfaces which is done in the + // ClientOptions class for v2 surfaces. + $options['clientConfig'] = json_decode( + file_get_contents($options['clientConfig']), + true + ); + self::validateFileExists($options['descriptorsConfigPath']); } $this->serviceName = $options['serviceName']; $this->retrySettings = RetrySettings::load( $this->serviceName, - $clientConfig, + $options['clientConfig'], $options['disableRetries'] ); + $headerInfo = [ + 'libName' => $options['libName'], + 'libVersion' => $options['libVersion'], + 'gapicVersion' => $options['gapicVersion'], + ]; // Edge case: If the client has the gRPC extension installed, but is // a REST-only library, then the grpcVersion header should not be set. if ($this->transport instanceof GrpcTransport) { - $options['grpcVersion'] = phpversion('grpc'); - unset($options['restVersion']); + $headerInfo['grpcVersion'] = phpversion('grpc'); } elseif ($this->transport instanceof RestTransport || $this->transport instanceof GrpcFallbackTransport) { - unset($options['grpcVersion']); - $options['restVersion'] = Version::getApiCoreVersion(); + $headerInfo['restVersion'] = Version::getApiCoreVersion(); } - + $this->agentHeader = AgentHeader::buildAgentHeader($headerInfo); + // Set "client_library_name" depending on client library surface being used $userAgentHeader = sprintf( 'gcloud-php-%s/%s', $this->isNewClientSurface() ? 'new' : 'legacy', $options['gapicVersion'] ); - $this->agentHeader = AgentHeader::buildAgentHeader( - $this->pluckArray([ - 'libName', - 'libVersion', - 'gapicVersion' - ], $options) - ); $this->agentHeader['User-Agent'] = [$userAgentHeader]; self::validateFileExists($options['descriptorsConfigPath']); + $descriptors = require($options['descriptorsConfigPath']); $this->descriptors = $descriptors['interfaces'][$this->serviceName]; @@ -449,7 +457,7 @@ private function createCredentialsWrapper($credentials, array $credentialsConfig /** * @param string $apiEndpoint * @param string $transport - * @param array $transportConfig + * @param TransportOptions|array $transportConfig * @param callable $clientCertSource * @return TransportInterface * @throws ValidationException @@ -457,7 +465,7 @@ private function createCredentialsWrapper($credentials, array $credentialsConfig private function createTransport( string $apiEndpoint, $transport, - array $transportConfig, + $transportConfig, callable $clientCertSource = null ) { if (!is_string($transport)) { @@ -475,7 +483,12 @@ private function createTransport( )); } $configForSpecifiedTransport = $transportConfig[$transport] ?? []; - $configForSpecifiedTransport['clientCertSource'] = $clientCertSource; + if (is_array($configForSpecifiedTransport)) { + $configForSpecifiedTransport['clientCertSource'] = $clientCertSource; + } else { + $configForSpecifiedTransport->setClientCertSource($clientCertSource); + $configForSpecifiedTransport = $configForSpecifiedTransport->toArray(); + } switch ($transport) { case 'grpc': // Setting the user agent for gRPC requires special handling @@ -715,6 +728,7 @@ private function startCall( int $callType = Call::UNARY_CALL, string $interfaceName = null ) { + $optionalArgs = $this->configureCallOptions($optionalArgs); $callStack = $this->createCallStack( $this->configureCallConstructionOptions($methodName, $optionalArgs) ); @@ -811,6 +825,19 @@ private function configureCallConstructionOptions(string $methodName, array $opt ]; } + /** + * @return array + */ + private function configureCallOptions(array $optionalArgs): array + { + if ($this->isNewClientSurface()) { + // cast to CallOptions for new surfaces only + return (new CallOptions($optionalArgs))->toArray(); + } + + return $optionalArgs; + } + /** * @param string $methodName * @param array $optionalArgs { @@ -836,6 +863,7 @@ private function startOperationsCall( string $interfaceName = null, string $operationClass = null ) { + $optionalArgs = $this->configureCallOptions($optionalArgs); $callStack = $this->createCallStack( $this->configureCallConstructionOptions($methodName, $optionalArgs) ); @@ -915,6 +943,7 @@ private function getPagedListResponseAsync( Message $request, string $interfaceName = null ) { + $optionalArgs = $this->configureCallOptions($optionalArgs); $callStack = $this->createCallStack( $this->configureCallConstructionOptions($methodName, $optionalArgs) ); diff --git a/src/Options/CallOptions.php b/src/Options/CallOptions.php new file mode 100644 index 000000000..db7680588 --- /dev/null +++ b/src/Options/CallOptions.php @@ -0,0 +1,140 @@ +fromArray($options); + } + + /** + * Sets the array of options as class properites. + * + * @param array $arr See the constructor for the list of supported options. + */ + private function fromArray(array $arr): void + { + $this->setHeaders($arr['headers'] ?? []); + $this->setTimeoutMillis($arr['timeoutMillis'] ?? null); + $this->setTransportSpecificOptions($arr['transportOptions'] ?? []); + $this->setRetrySettings($arr['retrySettings'] ?? null); + } + + /** + * @param array $headers + */ + public function setHeaders(array $headers) + { + $this->headers = $headers; + } + + /** + * @param int|null $timeoutMillis + */ + public function setTimeoutMillis(?int $timeoutMillis) + { + $this->timeoutMillis = $timeoutMillis; + } + + /** + * @param array $transportSpecificOptions { + * Transport-specific call-time options. + * + * @type array $grpcOptions + * Key-value pairs for gRPC-specific options passed as the `$options` argument to {@see \Grpc\BaseStub} + * request methods. Current options are `call_credentials_callback` and `timeout`. + * **NOTE**: This library sets `call_credentials_callback` using {@see CredentialsWrapper}, and `timeout` + * using the `timeoutMillis` call option, so these options are not very useful. + * @type array $grpcFallbackOptions + * Key-value pairs for gRPC fallback specific options passed as the `$options` argument to the + * `$httpHandler` callable. By default these are passed to {@see \GuzzleHttp\Client} as request options. + * See {@link https://docs.guzzlephp.org/en/stable/request-options.html}. + * @type array $restOptions + * Key-value pairs for REST-specific options passed as the `$options` argument to the `$httpHandler` + * callable. By default these are passed to {@see \GuzzleHttp\Client} as request options. + * See {@link https://docs.guzzlephp.org/en/stable/request-options.html}. + * } + */ + public function setTransportSpecificOptions(array $transportSpecificOptions) + { + $this->transportSpecificOptions = $transportSpecificOptions; + } + + /** + * @param RetrySettings|array|null $retrySettings + */ + public function setRetrySettings($retrySettings) + { + $this->retrySettings = $retrySettings; + } +} diff --git a/src/Options/ClientOptions.php b/src/Options/ClientOptions.php new file mode 100644 index 000000000..1ebf51016 --- /dev/null +++ b/src/Options/ClientOptions.php @@ -0,0 +1,304 @@ + '/path/to/my/credentials.json' + * ]); + * $secretManager = new SecretManagerClient($options->toArray()); + * ``` + * + * Note: It's possible to pass an associative array to the API clients as well, + * as ClientOptions will still be used internally for validation. + */ +class ClientOptions implements ArrayAccess +{ + use OptionsTrait; + + private ?string $apiEndpoint; + + private bool $disableRetries; + + private array $clientConfig; + + /** @var string|array|FetchAuthTokenInterface|CredentialsWrapper|null */ + private $credentials; + + private array $credentialsConfig; + + /** @var string|TransportInterface|null $transport */ + private $transport; + + private TransportOptions $transportConfig; + + private ?string $versionFile; + + private ?string $descriptorsConfigPath; + + private ?string $serviceName; + + private ?string $libName; + + private ?string $libVersion; + + private ?string $gapicVersion; + + private ?Closure $clientCertSource; + + /** + * @param array $options { + * @type string $apiEndpoint + * The address of the API remote host, for example "example.googleapis.com. May also + * include the port, for example "example.googleapis.com:443" + * @type bool $disableRetries + * Determines whether or not retries defined by the client configuration should be + * disabled. Defaults to `false`. + * @type string|array $clientConfig + * Client method configuration, including retry settings. This option can be either a + * path to a JSON file, or a PHP array containing the decoded JSON data. + * By default this settings points to the default client config file, which is provided + * in the resources folder. + * @type string|array|FetchAuthTokenInterface|CredentialsWrapper $credentials + * The credentials to be used by the client to authorize API calls. This option + * accepts either a path to a credentials file, or a decoded credentials file as a + * PHP array. + * *Advanced usage*: In addition, this option can also accept a pre-constructed + * \Google\Auth\FetchAuthTokenInterface object or \Google\ApiCore\CredentialsWrapper + * object. Note that when one of these objects are provided, any settings in + * $authConfig will be ignored. + * @type array $credentialsConfig + * Options used to configure credentials, including auth token caching, for the client. + * For a full list of supporting configuration options, see + * \Google\ApiCore\CredentialsWrapper::build. + * @type string|TransportInterface|null $transport + * The transport used for executing network requests. May be either the string `rest`, + * `grpc`, or 'grpc-fallback'. Defaults to `grpc` if gRPC support is detected on the system. + * *Advanced usage*: Additionally, it is possible to pass in an already instantiated + * TransportInterface object. Note that when this objects is provided, any settings in + * $transportConfig, and any `$apiEndpoint` setting, will be ignored. + * @type array $transportConfig + * Configuration options that will be used to construct the transport. Options for + * each supported transport type should be passed in a key for that transport. For + * example: + * $transportConfig = [ + * 'grpc' => [...], + * 'rest' => [...], + * 'grpc-fallback' => [...], + * ]; + * See the GrpcTransport::build and RestTransport::build + * methods for the supported options. + * @type string $versionFile + * The path to a file which contains the current version of the client. + * @type string $descriptorsConfigPath + * The path to a descriptor configuration file. + * @type string $serviceName + * The name of the service. + * @type string $libName + * The name of the client application. + * @type string $libVersion + * The version of the client application. + * @type string $gapicVersion + * The code generator version of the GAPIC library. + * @type callable $clientCertSource + * A callable which returns the client cert as a string. + * } + */ + public function __construct(array $options) + { + $this->fromArray($options); + } + + /** + * Sets the array of options as class properites. + * + * @param array $arr See the constructor for the list of supported options. + */ + private function fromArray(array $arr): void + { + $this->setApiEndpoint($arr['apiEndpoint'] ?? null); + $this->setDisableRetries($arr['disableRetries'] ?? false); + $this->setClientConfig($arr['clientConfig'] ?? []); + $this->setCredentials($arr['credentials']); + $this->setCredentialsConfig($arr['credentialsConfig'] ?? []); + $this->setTransport($arr['transport'] ?? null); + $this->setTransportConfig(new TransportOptions($arr['transportConfig'] ?? [])); + $this->setVersionFile($arr['versionFile'] ?? null); + $this->setDescriptorsConfigPath($arr['descriptorsConfigPath']); + $this->setServiceName($arr['serviceName'] ?? null); + $this->setLibName($arr['libName'] ?? null); + $this->setLibVersion($arr['libVersion'] ?? null); + $this->setGapicVersion($arr['gapicVersion'] ?? null); + $this->setClientCertSource($arr['clientCertSource'] ?? null); + } + + /** + * @param ?string $apiEndpoint + */ + public function setApiEndpoint(?string $apiEndpoint): void + { + $this->apiEndpoint = $apiEndpoint; + } + + /** + * @param bool $disableRetries + */ + public function setDisableRetries(bool $disableRetries): void + { + $this->disableRetries = $disableRetries; + } + + /** + * @param string|array $clientConfig + * @throws InvalidArgumentException + */ + public function setClientConfig($clientConfig): void + { + if (is_string($clientConfig)) { + $this->clientConfig = json_decode(file_get_contents($clientConfig), true); + } elseif (is_array($clientConfig)) { + $this->clientConfig = $clientConfig; + } else { + throw new InvalidArgumentException('Invalid client config'); + } + } + + /** + * @param string|array|FetchAuthTokenInterface|CredentialsWrapper|null $credentials + */ + public function setCredentials($credentials): void + { + $this->credentials = $credentials; + } + + /** + * @param array $credentialsConfig + */ + public function setCredentialsConfig(array $credentialsConfig): void + { + $this->credentialsConfig = $credentialsConfig; + } + + /** + * @param string|TransportInterface|null $transport + */ + public function setTransport($transport): void + { + $this->transport = $transport; + } + + /** + * @param TransportOptions $transportConfig + */ + public function setTransportConfig(TransportOptions $transportConfig): void + { + $this->transportConfig = $transportConfig; + } + + /** + * @param ?string $versionFile + */ + public function setVersionFile(?string $versionFile): void + { + $this->versionFile = $versionFile; + } + + /** + * @param ?string $descriptorsConfigPath + */ + private function setDescriptorsConfigPath(?string $descriptorsConfigPath) + { + if (!is_null($descriptorsConfigPath)) { + self::validateFileExists($descriptorsConfigPath); + } + $this->descriptorsConfigPath = $descriptorsConfigPath; + } + + /** + * @param ?string $serviceName + */ + public function setServiceName(?string $serviceName): void + { + $this->serviceName = $serviceName; + } + + /** + * @param ?string $libName + */ + public function setLibName(?string $libName): void + { + $this->libName = $libName; + } + + /** + * @param ?string $libVersion + */ + public function setLibVersion(?string $libVersion): void + { + $this->libVersion = $libVersion; + } + + /** + * @param ?string $gapicVersion + */ + public function setGapicVersion(?string $gapicVersion): void + { + $this->gapicVersion = $gapicVersion; + } + + /** + * @param ?callable $clientCertSource + */ + public function setClientCertSource(?callable $clientCertSource) + { + if (!is_null($clientCertSource)) { + $this->clientCertSource = Closure::fromCallable($clientCertSource); + } + $this->clientCertSource = $clientCertSource; + } +} diff --git a/src/Options/OptionsTrait.php b/src/Options/OptionsTrait.php new file mode 100644 index 000000000..9c721f4ac --- /dev/null +++ b/src/Options/OptionsTrait.php @@ -0,0 +1,90 @@ +$offset); + } + + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->$offset; + } + + /** + * @throws BadMethodCallException + */ + public function offsetSet($offset, $value): void + { + throw new BadMethodCallException('Cannot set options through array access. Use the setters instead'); + } + + /** + * @throws BadMethodCallException + */ + public function offsetUnset($offset): void + { + throw new BadMethodCallException('Cannot unset options through array access. Use the setters instead'); + } + + public function toArray(): array + { + $arr = []; + foreach (get_object_vars($this) as $key => $value) { + $arr[$key] = $value; + } + return $arr; + } +} diff --git a/src/Options/TransportOptions.php b/src/Options/TransportOptions.php new file mode 100644 index 000000000..dd30267ca --- /dev/null +++ b/src/Options/TransportOptions.php @@ -0,0 +1,90 @@ +fromArray($options); + } + + /** + * Sets the array of options as class properites. + * + * @param array $arr See the constructor for the list of supported options. + */ + private function fromArray(array $arr): void + { + $this->setGrpc(new GrpcTransportOptions($arr['grpc'] ?? [])); + $this->setGrpcFallback(new GrpcFallbackTransportOptions($arr['grpc-fallack'] ?? [])); + $this->setRest(new RestTransportOptions($arr['rest'] ?? [])); + } + + public function setGrpc(GrpcTransportOptions $grpc): void + { + $this->grpc = $grpc; + } + + public function setGrpcFallback(GrpcFallbackTransportOptions $grpcFallback): void + { + $this->grpcFallback = $grpcFallback; + } + + public function setRest(RestTransportOptions $rest): void + { + $this->rest = $rest; + } +} diff --git a/src/Options/TransportOptions/GrpcFallbackTransportOptions.php b/src/Options/TransportOptions/GrpcFallbackTransportOptions.php new file mode 100644 index 000000000..6f67712c7 --- /dev/null +++ b/src/Options/TransportOptions/GrpcFallbackTransportOptions.php @@ -0,0 +1,95 @@ +fromArray($options); + } + + /** + * Sets the array of options as class properites. + * + * @param array $arr See the constructor for the list of supported options. + */ + private function fromArray(array $arr): void + { + $this->setClientCertSource($arr['clientCertSource'] ?? null); + $this->setHttpHandler($arr['httpHandler'] ?? null); + } + + public function setHttpHandler(?callable $httpHandler) + { + if (!is_null($httpHandler)) { + $this->httpHandler = Closure::fromCallable($httpHandler); + } + $this->httpHandler = $httpHandler; + } + + /** + * @param ?callable $clientCertSource + */ + public function setClientCertSource(?callable $clientCertSource) + { + if (!is_null($clientCertSource)) { + $this->clientCertSource = Closure::fromCallable($clientCertSource); + } + $this->clientCertSource = $clientCertSource; + } +} diff --git a/src/Options/TransportOptions/GrpcTransportOptions.php b/src/Options/TransportOptions/GrpcTransportOptions.php new file mode 100644 index 000000000..3a2bb5076 --- /dev/null +++ b/src/Options/TransportOptions/GrpcTransportOptions.php @@ -0,0 +1,131 @@ +fromArray($options); + } + + /** + * Sets the array of options as class properites. + * + * @param array $arr See the constructor for the list of supported options. + */ + private function fromArray(array $arr): void + { + $this->setStubOpts($arr['stubOpts'] ?? []); + $this->setChannel($arr['channel'] ?? null); + $this->setInterceptors($arr['interceptors'] ?? []); + $this->setClientCertSource($arr['clientCertSource'] ?? null); + } + + /** + * @param array $stubOpts + */ + public function setStubOpts(array $stubOpts) + { + $this->stubOpts = $stubOpts; + } + + /** + * @param ?Channel $channel + */ + public function setChannel(?Channel $channel) + { + $this->channel = $channel; + } + + /** + * @param Interceptor[]|UnaryInterceptorInterface[] $interceptors + */ + public function setInterceptors(array $interceptors) + { + $this->interceptors = $interceptors; + } + + /** + * @param ?callable $clientCertSource + */ + public function setClientCertSource(?callable $clientCertSource) + { + if (!is_null($clientCertSource)) { + $this->clientCertSource = Closure::fromCallable($clientCertSource); + } + $this->clientCertSource = $clientCertSource; + } +} diff --git a/src/Options/TransportOptions/RestTransportOptions.php b/src/Options/TransportOptions/RestTransportOptions.php new file mode 100644 index 000000000..23229db7d --- /dev/null +++ b/src/Options/TransportOptions/RestTransportOptions.php @@ -0,0 +1,111 @@ +fromArray($options); + } + + /** + * Sets the array of options as class properites. + * + * @param array $arr See the constructor for the list of supported options. + */ + private function fromArray(array $arr): void + { + $this->setHttpHandler($arr['httpHandler'] ?? null); + $this->setClientCertSource($arr['clientCertSource'] ?? null); + $this->setRestClientConfigPath($arr['restClientConfigPath'] ?? null); + } + + /** + * @param ?callable $httpHandler + */ + public function setHttpHandler(?callable $httpHandler) + { + if (!is_null($httpHandler)) { + $this->httpHandler = Closure::fromCallable($httpHandler); + } + $this->httpHandler = $httpHandler; + } + + /** + * @param ?callable $clientCertSource + */ + public function setClientCertSource(?callable $clientCertSource) + { + if (!is_null($clientCertSource)) { + $this->clientCertSource = Closure::fromCallable($clientCertSource); + } + $this->clientCertSource = $clientCertSource; + } + + /** + * @param ?string $restClientConfigPath + */ + public function setRestClientConfigPath(?string $restClientConfigPath) + { + $this->restClientConfigPath = $restClientConfigPath; + } +} diff --git a/src/Transport/GrpcTransport.php b/src/Transport/GrpcTransport.php index 252e96276..d71eae272 100644 --- a/src/Transport/GrpcTransport.php +++ b/src/Transport/GrpcTransport.php @@ -98,7 +98,8 @@ public function __construct(string $hostname, array $opts, Channel $channel = nu * @param array $config { * Config options used to construct the gRPC transport. * - * @type array $stubOpts Options used to construct the gRPC stub. + * @type array $stubOpts Options used to construct the gRPC stub (see + * {@link https://grpc.github.io/grpc/core/group__grpc__arg__keys.html}). * @type Channel $channel Grpc channel to be used. * @type Interceptor[]|UnaryInterceptorInterface[] $interceptors *EXPERIMENTAL* * Interceptors used to intercept RPC invocations before a call starts. diff --git a/tests/Tests/Unit/GapicClientTraitTest.php b/tests/Tests/Unit/GapicClientTraitTest.php index 9c2f5f756..5ebb3b226 100644 --- a/tests/Tests/Unit/GapicClientTraitTest.php +++ b/tests/Tests/Unit/GapicClientTraitTest.php @@ -41,6 +41,7 @@ use Google\ApiCore\GapicClientTrait; use Google\ApiCore\LongRunning\OperationsClient; use Google\ApiCore\OperationResponse; +use Google\ApiCore\Options\TransportOptions; use Google\ApiCore\RequestParamsHeaderDescriptor; use Google\ApiCore\RetrySettings; use Google\ApiCore\ServerStream; @@ -1124,7 +1125,7 @@ public function testModifyClientOptions() $this->assertTrue($updatedOptions['disableRetries']); } - private function buildClientToTestModifyCallMethods() + private function buildClientToTestModifyCallMethods($clientClass = null) { $header = AgentHeader::buildAgentHeader([]); $retrySettings = $this->getMockBuilder(RetrySettings::class) @@ -1151,7 +1152,8 @@ private function buildClientToTestModifyCallMethods() $credentialsWrapper = CredentialsWrapper::build([ 'keyFile' => __DIR__ . '/testdata/json-key-file.json' ]); - $client = new GapicClientTraitStubExtension(); + $clientClass = $clientClass ?: GapicClientTraitStubExtension::class; + $client = new $clientClass(); $client->set('transport', $transport); $client->set('credentialsWrapper', $credentialsWrapper); $client->set('agentHeader', $header); @@ -1573,13 +1575,13 @@ public function testSupportedTransportOverrideWithInvalidTransport() public function testSupportedTransportOverrideWithDefaultTransport() { $client = new GapicClientTraitRestOnly(); - $this->assertInstanceOf(RestTransport::class, $client->getTransport()); + $this->assertInstanceOf(RestTransport::class, $client->call('getTransport')); } public function testSupportedTransportOverrideWithExplicitTransport() { $client = new GapicClientTraitRestOnly(['transport' => 'rest']); - $this->assertInstanceOf(RestTransport::class, $client->getTransport()); + $this->assertInstanceOf(RestTransport::class, $client->call('getTransport')); } /** @dataProvider provideDetermineMtlsEndpoint */ @@ -1720,6 +1722,105 @@ public function testMtlsClientOptionWithDefaultClientCertSource() $this->assertEquals(['foo', 'foo'], $options['clientCertSource']()); } + public function testInvalidClientOptionsTypeThrowsExceptionForV2SurfaceOnly() + { + // v1 client + new GapicClientTraitStub(['apiEndpoint' => ['foo']]); + $this->assertTrue(true, 'Test made it to here without throwing an exception'); + + $this->expectException(\TypeError::class); + $this->expectExceptionMessage( + PHP_MAJOR_VERSION < 8 + ? 'Argument 1 passed to Google\ApiCore\Options\ClientOptions::setApiEndpoint() ' + . 'must be of the type string or null, array given' + : 'Google\ApiCore\Options\ClientOptions::setApiEndpoint(): Argument #1 ' + . '($apiEndpoint) must be of type ?string, array given' + ); + + // v2 client + new GapicV2SurfaceClient(['apiEndpoint' => ['foo']]); + } + + public function testCallOptionsForV2Surface() + { + list($client, $transport) = $this->buildClientToTestModifyCallMethods( + GapicV2SurfaceClient::class + ); + + $transport->expects($this->once()) + ->method('startUnaryCall') + ->with( + $this->isInstanceOf(Call::class), + $this->equalTo([ + 'headers' => AgentHeader::buildAgentHeader([]) + ['Foo' => 'Bar'], + 'credentialsWrapper' => CredentialsWrapper::build([ + 'keyFile' => __DIR__ . '/testdata/json-key-file.json' + ]), + 'timeoutMillis' => null, // adds null timeoutMillis + ]) + ) + ->willReturn(new FulfilledPromise(new Operation())); + + $callOptions = [ + 'headers' => ['Foo' => 'Bar'], + 'invalidOption' => 'wont-be-passed' + ]; + $client->call('startCall', [ + 'simpleMethod', + 'decodeType', + $callOptions, + new MockRequest(), + ])->wait(); + } + + public function testInvalidCallOptionsTypeForV1SurfaceDoesNotThrowException() + { + list($client, $transport) = $this->buildClientToTestModifyCallMethods(); + + $transport->expects($this->once()) + ->method('startUnaryCall') + ->with( + $this->isInstanceOf(Call::class), + $this->equalTo([ + 'transportOptions' => ['custom' => ['addModifyUnaryCallableOption' => true]], + 'headers' => AgentHeader::buildAgentHeader([]), + 'credentialsWrapper' => CredentialsWrapper::build([ + 'keyFile' => __DIR__ . '/testdata/json-key-file.json' + ]), + 'timeoutMillis' => 'blue', // invalid type, this is ignored + ]) + ) + ->willReturn(new FulfilledPromise(new Operation())); + + $client->call('startCall', [ + 'simpleMethod', + 'decodeType', + ['timeoutMillis' => 'blue'], + new MockRequest(), + ])->wait(); + } + + public function testInvalidCallOptionsTypeForV2SurfaceThrowsException() + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage( + PHP_MAJOR_VERSION < 8 + ? 'Argument 1 passed to Google\ApiCore\Options\CallOptions::setTimeoutMillis() ' + . 'must be of the type int or null, string given' + : 'Google\ApiCore\Options\CallOptions::setTimeoutMillis(): Argument #1 ' + . '($timeoutMillis) must be of type ?int, string given' + ); + + list($client, $_) = $this->buildClientToTestModifyCallMethods(GapicV2SurfaceClient::class); + + $client->call('startCall', [ + 'simpleMethod', + 'decodeType', + ['timeoutMillis' => 'blue'], // invalid type, will throw exception + new MockRequest(), + ])->wait(); + } + public function testSurfaceAgentHeaders() { // V1 does not contain new headers @@ -1743,6 +1844,7 @@ public function testSurfaceAgentHeaders() class GapicClientTraitStub { use GapicClientTrait; + use GapicClientStubTrait; public static function getClientDefaults() { @@ -1763,7 +1865,10 @@ public static function getClientDefaults() ], ]; } +} +trait GapicClientStubTrait +{ public function call($fn, array $args = []) { return call_user_func_array([$this, $fn], $args); @@ -1824,6 +1929,7 @@ protected function modifyStreamingCallable(callable &$callable) class GapicClientTraitDefaultScopeAndAudienceStub { use GapicClientTrait; + use GapicClientStubTrait; const SERVICE_ADDRESS = 'service-address'; @@ -1841,28 +1947,12 @@ public static function getClientDefaults() ], ]; } - - public function call($fn, array $args = []) - { - return call_user_func_array([$this, $fn], $args); - } - - public function set($name, $val, $static = false) - { - if (!property_exists($this, $name)) { - throw new \InvalidArgumentException("Property not found: $name"); - } - if ($static) { - $this::$$name = $val; - } else { - $this->$name = $val; - } - } } class GapicClientTraitRestOnly { use GapicClientTrait; + use GapicClientStubTrait; public function __construct($options = []) { @@ -1885,16 +1975,6 @@ public static function getClientDefaults() ]; } - public function call($fn, array $args = []) - { - return call_user_func_array([$this, $fn], $args); - } - - public function getTransport() - { - return $this->transport; - } - private static function supportedTransports() { return ['rest', 'fake-transport']; @@ -1928,9 +2008,10 @@ public function getOperation($name, $arg1, $arg2) } } -class GapicV2SurfaceBaseClient +abstract class GapicV2SurfaceBaseClient { use GapicClientTrait; + use GapicClientStubTrait; public function __construct(array $options = []) { diff --git a/tests/Tests/Unit/Options/OptionsTraitTest.php b/tests/Tests/Unit/Options/OptionsTraitTest.php new file mode 100644 index 000000000..c3e5753f9 --- /dev/null +++ b/tests/Tests/Unit/Options/OptionsTraitTest.php @@ -0,0 +1,139 @@ + 'foo', + 'option4' => 'bar', + ]); + + $this->assertArrayHasKey('option1', $options->toArray()); + $this->assertArrayHasKey('option2', $options->toArray()); + $this->assertArrayNotHasKey('option3', $options->toArray()); + $this->assertArrayNotHasKey('option4', $options->toArray()); + } + + public function testInvalidTypesThrowException() + { + $this->expectException(TypeError::class); + $this->expectExceptionMessage(PHP_MAJOR_VERSION < 8 + ? 'Google\ApiCore\Tests\Unit\Options\OptionsTraitStub::$option2 must be int or null, string used' + : 'Cannot assign string to property Google\ApiCore\Tests\Unit\Options\OptionsTraitStub::$option2 of type ?int' + ); + + $options = new OptionsTraitStub([ + 'option1' => 123, // this is okay because it is cast to a string + 'option2' => 'bar', // this will throw an exception + ]); + } + + public function testArrayGet() + { + $options = new OptionsTraitStub(['option1' => 'abc']); + $this->assertEquals('abc', $options['option1']); + } + + public function testArrayIsset() + { + $options = new OptionsTraitStub(['option1' => 'abc']); + $this->assertTrue(isset($options['option1'])); + $this->assertFalse(isset($options['option2'])); // valid option + $this->assertFalse(isset($options['option3'])); // invalid option + } + + public function testArraySetThrowsException() + { + $this->expectException(BadMethodCallException::class); + $options = new OptionsTraitStub([]); + $options['option1'] = 'abc'; + } + + public function testArrayUnsetThrowsException() + { + $this->expectException(BadMethodCallException::class); + $options = new OptionsTraitStub([]); + unset($options['option1']); + } + + public function testInvalidFilePathThrowsException() + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Could not find specified file: does/not/exist.php'); + new OptionsTraitStub(['file' => 'does/not/exist.php']); + } + + public function testValidateFileExists() + { + $options = new OptionsTraitStub(['option1' => 'foo', 'file' => __FILE__]); + $this->assertEquals(__FILE__, $options['file']); + } +} + +class OptionsTraitStub implements ArrayAccess +{ + use OptionsTrait; + + private ?string $option1; + private ?int $option2; + private ?string $file; + + public function __construct(array $options) + { + $this->option1 = $options['option1'] ?? null; + $this->option2 = $options['option2'] ?? null; + $this->setFile($options['file'] ?? null); + } + + private function setFile(?string $file) + { + if (!is_null($file)) { + self::validateFileExists($file); + } + $this->file = $file; + } +}