Skip to content

Commit 96be3cb

Browse files
authored
feat(Core): add OptionsValidator (#8627)
* feat(Core): add OptionsValidator
1 parent afbd774 commit 96be3cb

4 files changed

Lines changed: 140 additions & 41 deletions

File tree

Core/src/ApiHelperTrait.php

Lines changed: 5 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,10 @@
1717
*/
1818
namespace Google\Cloud\Core;
1919

20-
use Exception;
2120
use Google\ApiCore\ArrayTrait;
2221
use Google\ApiCore\Options\CallOptions;
2322
use Google\Protobuf\Internal\Message;
2423
use Google\Protobuf\NullValue;
25-
use LogicException;
2624

2725
/**
2826
* @internal
@@ -33,6 +31,8 @@ trait ApiHelperTrait
3331
use ArrayTrait;
3432
use TimeTrait;
3533

34+
private OptionsValidator $optionsValidator;
35+
3636
/**
3737
* Format a struct for the API.
3838
*
@@ -276,40 +276,9 @@ private function splitOptionalArgs(array $input, array $extraAllowedKeys = []):
276276
*/
277277
private function validateOptions(array $options, array|Message|string ...$optionTypes): array
278278
{
279-
$splitOptions = [];
280-
foreach ($optionTypes as $optionType) {
281-
if (is_array($optionType)) {
282-
$splitOptions[] = $this->pluckArray($optionType, $options);
283-
} elseif ($optionType === CallOptions::class) {
284-
$callOptionKeys = array_keys((new CallOptions([]))->toArray());
285-
$splitOptions[] = $this->pluckArray($callOptionKeys, $options);
286-
} elseif ($optionType instanceof Message) {
287-
$messageKeys = array_map(
288-
fn ($method) => lcfirst(substr($method, 3)),
289-
array_filter(
290-
get_class_methods($optionType),
291-
fn ($m) => 0 === strpos($m, 'get')
292-
)
293-
);
294-
$messageOptions = $this->pluckArray($messageKeys, $options);
295-
if ($optionType instanceof Message) {
296-
$optionType->mergeFromJsonString(json_encode($messageOptions, JSON_FORCE_OBJECT));
297-
$validatedOptionGroup = $optionType;
298-
} else {
299-
$validatedOptionGroup = $messageOptions;
300-
}
301-
$splitOptions[] = $validatedOptionGroup;
302-
} else {
303-
throw new LogicException(sprintf('Invalid option type: %s', $optionType));
304-
}
279+
if (!isset($this->optionsValidator)) {
280+
$this->optionsValidator = new OptionsValidator();
305281
}
306-
307-
if (!empty($options)) {
308-
throw new Exception(
309-
'Unexpected option(s) provided: ' . implode(', ', array_keys($options))
310-
);
311-
}
312-
313-
return $splitOptions;
282+
return $this->optionsValidator->validateOptions($options, ...$optionTypes);
314283
}
315284
}

Core/src/OptionsValidator.php

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2025 Google Inc. All Rights Reserved.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
namespace Google\Cloud\Core;
19+
20+
use Google\ApiCore\ArrayTrait;
21+
use Google\ApiCore\Options\CallOptions;
22+
use Google\ApiCore\Serializer;
23+
use Google\Protobuf\Internal\Message;
24+
use LogicException;
25+
26+
/**
27+
* Helper used to validate options
28+
*
29+
* @internal
30+
*/
31+
class OptionsValidator
32+
{
33+
use ArrayTrait;
34+
35+
/**
36+
* @param ?Serializer $serializer use a serializer to decode protobuf messages
37+
* instead of calling {@see Message::mergeFromJsonString()}.
38+
*/
39+
public function __construct(private ?Serializer $serializer = null)
40+
{
41+
}
42+
43+
/**
44+
* Validate an array of options based on the supplied `$optionTypes`.
45+
* $optionTypes can be an array of string keys, a protobuf Message classname, or a
46+
* the CallOptions classname. Parameters are split and returned in the order
47+
* that the options types are provided.
48+
*
49+
* - If the option type is an array, any keys in $options matching the string values
50+
* of the array are returned.
51+
* - If the option type is {@see Message}, any keys matching getters will be set on the message.
52+
* - If the option type is string, and that string is a valid {@see CallOptions} option, those
53+
* options will be returned in an array
54+
*
55+
* ```
56+
* [$customOps, $commitRequest, $callOptions] = $optionsValidator->vaidateOptions(
57+
* $options,
58+
* ['customOp1', 'customOp2'],
59+
* new CommitRequest(),
60+
* CallOptions::class,
61+
* );
62+
* ```
63+
*
64+
* @param array $options
65+
* @param array|Message|string ...$optionTypes
66+
* @return array
67+
* @throws LogicException when a value exists which is not supported by any of the `$optionTypes`.
68+
*/
69+
public function validateOptions(array $options, array|Message|string ...$optionTypes): array
70+
{
71+
$splitOptions = [];
72+
foreach ($optionTypes as $optionType) {
73+
if (is_array($optionType)) {
74+
$splitOptions[] = $this->pluckArray($optionType, $options);
75+
} elseif ($optionType === CallOptions::class) {
76+
$callOptionKeys = array_keys((new CallOptions([]))->toArray());
77+
$splitOptions[] = $this->pluckArray($callOptionKeys, $options);
78+
} elseif ($optionType instanceof Message) {
79+
$messageKeys = array_map(
80+
fn ($method) => lcfirst(substr($method, 3)),
81+
array_filter(
82+
get_class_methods($optionType),
83+
fn ($m) => 0 === strpos($m, 'get')
84+
)
85+
);
86+
$messageOptions = $this->pluckArray($messageKeys, $options);
87+
if ($this->serializer) {
88+
$optionType = $this->serializer->decodeMessage($optionType, $messageOptions);
89+
} else {
90+
$optionType->mergeFromJsonString(json_encode($messageOptions, JSON_FORCE_OBJECT));
91+
}
92+
$splitOptions[] = $optionType;
93+
} else {
94+
throw new LogicException(sprintf('Invalid option type: %s', $optionType));
95+
}
96+
}
97+
98+
if (!empty($options)) {
99+
throw new LogicException(
100+
'Unexpected option(s) provided: ' . implode(', ', array_keys($options))
101+
);
102+
}
103+
104+
return $splitOptions;
105+
}
106+
}

Core/tests/Unit/ApiHelperTraitTest.php

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@
2121
use Google\ApiCore\Serializer;
2222
use Google\ApiCore\Testing\MockRequest;
2323
use Google\Cloud\Core\Duration;
24+
use Google\Cloud\Core\OptionsValidator;
2425
use Google\Cloud\Core\Testing\GrpcTestTrait;
2526
use Google\Cloud\Core\Tests\Unit\Stubs\ApiHelpersTraitImpl;
27+
use Google\Protobuf\Internal\Message;
2628
use LogicException;
2729
use PHPUnit\Framework\TestCase;
30+
use Prophecy\Argument;
2831
use Prophecy\PhpUnit\ProphecyTrait;
2932

3033
/**
@@ -40,7 +43,6 @@ class ApiHelperTraitTest extends TestCase
4043
public function setUp(): void
4144
{
4245
$this->implementation = new ApiHelpersTraitImpl();
43-
$this->implementation->serializer = new Serializer();
4446
}
4547

4648
public function testFormatsTimestamp()
@@ -269,14 +271,32 @@ public function unpackValueProvider()
269271
*/
270272
public function testValidateOptions($options, $optionTypes, $expected)
271273
{
274+
$implementation = new ApiHelpersTraitImpl();
272275
$this->assertEquals(
273276
$expected,
274-
$this->implementation->validateOptions($options, ...$optionTypes)
277+
$implementation->validateOptions($options, ...$optionTypes)
275278
);
276-
// test using an implementation without a serializer
279+
}
280+
281+
/**
282+
* @dataProvider validateOptionsProvider
283+
*/
284+
public function testValidateOptionsCustomSerializer($options, $optionTypes, $expected)
285+
{
286+
$numMessages = count(array_filter($expected, fn ($optionType) => $optionType instanceof Message));
287+
$serializer = $this->prophesize(Serializer::class);
288+
$serializer->decodeMessage(Argument::type(Message::class), Argument::type('array'))
289+
->shouldBeCalledTimes($numMessages)
290+
->will(function ($args) {
291+
return (new Serializer())->decodeMessage($args[0], $args[1]);
292+
});
293+
294+
// test using an implementation with custom serializer
295+
$implementation = new ApiHelpersTraitImpl();
296+
$implementation->setOptionsValidator(new OptionsValidator($serializer->reveal()));
277297
$this->assertEquals(
278298
$expected,
279-
(new ApiHelpersTraitImpl)->validateOptions($options, ...$optionTypes)
299+
$implementation->validateOptions($options, ...$optionTypes)
280300
);
281301
}
282302

Core/tests/Unit/Stubs/ApiHelpersTraitImpl.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
use Google\ApiCore\ArrayTrait;
2121
use Google\Cloud\Core\ApiHelperTrait;
22+
use Google\Cloud\Core\OptionsValidator;
2223

2324
class ApiHelpersTraitImpl
2425
{
@@ -34,5 +35,8 @@ class ApiHelpersTraitImpl
3435
validateOptions as public;
3536
}
3637

37-
public $serializer;
38+
public function setOptionsValidator(OptionsValidator $optionsValidator)
39+
{
40+
$this->optionsValidator = $optionsValidator;
41+
}
3842
}

0 commit comments

Comments
 (0)