Skip to content

Commit cacd25c

Browse files
author
Guy Elsmore-Paddock
committed
[guzzle#165] Add Support for Polymorphic Response Field Types
This allows fields within a response model to vary within a set of allowed types instead of being required to be a specific JSON schema value type. This also corrects detection of null response values. It also fixes detection of associative vs. indexed arrays so that the order in which the type definitions appear in the service description doesn't affect which type gets returned when a field can be either an object or an array. Closes guzzle#165.
1 parent d14ba44 commit cacd25c

File tree

4 files changed

+201
-9
lines changed

4 files changed

+201
-9
lines changed

src/Parameter.php

+97
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,103 @@ public function getFormat()
595595
return $this->format;
596596
}
597597

598+
/**
599+
* From the allowable types, determine the type that a value matches.
600+
*
601+
* @param mixed $value Value for which a type should be determined.
602+
*
603+
* @return string|false Either the type that matches the specified value, or
604+
* false if a determination isn't possible.
605+
*/
606+
public function determineType($value)
607+
{
608+
$type = $this->getType();
609+
610+
foreach ((array) $type as $t) {
611+
if ($t == 'string'
612+
&& (is_string($value) || (is_object($value) && method_exists($value, '__toString')))
613+
) {
614+
return 'string';
615+
} elseif ($t == 'object' && (is_object($value) || $this->isAssociativeArray($value))) {
616+
return 'object';
617+
} elseif ($t == 'array' && $this->isIndexedArray($value)) {
618+
return 'array';
619+
} elseif ($t == 'integer' && is_integer($value)) {
620+
return 'integer';
621+
} elseif ($t == 'boolean' && is_bool($value)) {
622+
return 'boolean';
623+
} elseif ($t == 'number' && is_numeric($value)) {
624+
return 'number';
625+
} elseif ($t == 'numeric' && is_numeric($value)) {
626+
return 'numeric';
627+
} elseif ($t == 'null' && !$value) {
628+
return 'null';
629+
} elseif ($t == 'any') {
630+
return 'any';
631+
}
632+
}
633+
634+
return false;
635+
}
636+
637+
/**
638+
* Determine whether or not a given value is an associative array or not.
639+
*
640+
* This is needed to help disambiguate an array that can be encoded as a
641+
* JSON object (associative array) from one that can be encoded as a JSON
642+
* array (non-associative array).
643+
*
644+
* Special case: an empty array is considered to be an associative array,
645+
* vacuously.
646+
*
647+
* @param mixed $value
648+
* The value to be checked.
649+
*
650+
* @return boolean
651+
* TRUE if the value is an empty or associative array; FALSE if the value
652+
* is not an array, or is an indexed array.
653+
*/
654+
private function isAssociativeArray($value) {
655+
if (!is_array($value)) {
656+
return false;
657+
}
658+
659+
$is_empty = ($value === []);
660+
661+
// If array without any associative keys is strictly equal to the array,
662+
// it's not an associative array.
663+
$has_numeric_keys = (array_values($value) !== $value);
664+
665+
return $is_empty || $has_numeric_keys;
666+
}
667+
668+
/**
669+
* Determine whether or not a given value is an indexed array or not.
670+
*
671+
* This is needed to help disambiguate an array that can be encoded as a
672+
* JSON object (associative array) from one that can be encoded as a JSON
673+
* array (non-associative array).
674+
*
675+
* Special case: an empty array is considered to be an indexed array,
676+
* vacuously.
677+
*
678+
* @param mixed $value
679+
* The value to be checked.
680+
*
681+
* @return boolean
682+
* TRUE if the value is an empty or indexed array; FALSE if the value is
683+
* not an array, or is an associative array.
684+
*/
685+
private function isIndexedArray($value) {
686+
if (!is_array($value)) {
687+
return false;
688+
}
689+
690+
$is_empty = ($value === []);
691+
692+
return $is_empty || !$this->isAssociativeArray($value);
693+
}
694+
598695
/**
599696
* Set the array of filters used by the parameter
600697
*

src/ResponseLocation/JsonLocation.php

+19-8
Original file line numberDiff line numberDiff line change
@@ -97,22 +97,33 @@ public function visit(
9797
$name = $param->getName();
9898
$key = $param->getWireName();
9999

100+
$wholeValue = $this->json;
101+
102+
if (array_key_exists($key, $wholeValue)) {
103+
$haveNestedElement = true;
104+
$nestedElement = $wholeValue[$key];
105+
} else {
106+
$haveNestedElement = false;
107+
$nestedElement = null;
108+
}
109+
110+
$valueType = $param->determineType($wholeValue);
111+
100112
// Check if the result should be treated as a list
101-
if ($param->getType() == 'array') {
113+
if ($valueType === 'array') {
102114
// Treat as javascript array
103-
if ($name) {
115+
if (!empty($name)) {
104116
// name provided, store it under a key in the array
105-
$subArray = isset($this->json[$key]) ? $this->json[$key] : null;
106-
$result[$name] = $this->recurse($param, $subArray);
117+
$result[$name] = $this->recurse($param, $nestedElement);
107118
} else {
108119
// top-level `array` or an empty name
109120
$result = new Result(array_merge(
110121
$result->toArray(),
111-
$this->recurse($param, $this->json)
122+
$this->recurse($param, $wholeValue)
112123
));
113124
}
114-
} elseif (isset($this->json[$key])) {
115-
$result[$name] = $this->recurse($param, $this->json[$key]);
125+
} elseif ($haveNestedElement) {
126+
$result[$name] = $this->recurse($param, $nestedElement);
116127
}
117128

118129
return $result;
@@ -132,7 +143,7 @@ private function recurse(Parameter $param, $value)
132143
}
133144

134145
$result = [];
135-
$type = $param->getType();
146+
$type = $param->determineType($value);
136147

137148
if ($type == 'array') {
138149
$items = $param->getItems();

src/SchemaValidator.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ protected function recursiveProcess(
232232
// Validate that the type is correct. If the type is string but an
233233
// integer was passed, the class can be instructed to cast the integer
234234
// to a string to pass validation. This is the default behavior.
235-
if ($type && (!$type = $this->determineType($type, $value))) {
235+
if ($type && (!$type = $param->determineType($value))) {
236236
if ($this->castIntegerToStringType
237237
&& $param->getType() == 'string'
238238
&& is_integer($value)

tests/ResponseLocation/JsonLocationTest.php

+84
Original file line numberDiff line numberDiff line change
@@ -578,4 +578,88 @@ public function testVisitsNestedArrayOfObjects()
578578
];
579579
$this->assertEquals($expected, $result->toArray());
580580
}
581+
582+
public function polymorphicProvider()
583+
{
584+
return [
585+
[
586+
['null', 'string', 'object', 'array'],
587+
'{"value": null}',
588+
['value' => null]
589+
],
590+
[
591+
['null', 'string', 'object', 'array'],
592+
'{"value": "foo"}',
593+
['value' => 'foo']
594+
],
595+
[
596+
['null', 'string', 'object', 'array'],
597+
'{"value": ["a", "b", "c"]}',
598+
['value' => ['a', 'b', 'c']]
599+
],
600+
[
601+
['null', 'string', 'array', 'object'],
602+
'{"value": ["a", "b", "c"]}',
603+
['value' => ['a', 'b', 'c']]
604+
],
605+
[
606+
['null', 'string', 'object', 'array'],
607+
'{"value": {"worked": true, "failed": false}}',
608+
['value' => ['worked' => true, 'failed' => false]]
609+
],
610+
[
611+
['null', 'string', 'array', 'object'],
612+
'{"value": {"worked": true, "failed": false}}',
613+
['value' => ['worked' => true, 'failed' => false]]
614+
]
615+
];
616+
}
617+
618+
619+
/**
620+
* @dataProvider polymorphicProvider
621+
* @group ResponseLocation
622+
*/
623+
public function testRecognizesPolymorphicReturnTypes($allowedTypes, $responseJson,
624+
$expectedOutput)
625+
{
626+
$json = json_decode($responseJson);
627+
628+
$body = \GuzzleHttp\json_encode($json);
629+
$response = new Response(200, ['Content-Type' => 'application/json'], $body);
630+
$mock = new MockHandler([$response]);
631+
632+
$httpClient = new Client(['handler' => $mock]);
633+
634+
$description = new Description([
635+
'operations' => [
636+
'foo' => [
637+
'uri' => 'http://httpbin.org',
638+
'httpMethod' => 'GET',
639+
'responseModel' => 'j'
640+
]
641+
],
642+
'models' => [
643+
'j' => [
644+
'type' => 'object',
645+
'location' => 'json',
646+
'properties' => [
647+
'value' => [
648+
'type' => $allowedTypes,
649+
'items' => [
650+
'type' => 'any'
651+
]
652+
]
653+
]
654+
]
655+
]
656+
]);
657+
658+
$guzzle = new GuzzleClient($httpClient, $description);
659+
660+
/** @var ResultInterface $result */
661+
$result = $guzzle->foo();
662+
663+
$this->assertEquals($expectedOutput, $result->toArray());
664+
}
581665
}

0 commit comments

Comments
 (0)