Skip to content

Commit

Permalink
[#165] Add Support for Polymorphic Response Field Types
Browse files Browse the repository at this point in the history
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 #165.
  • Loading branch information
Guy Elsmore-Paddock committed Aug 13, 2019
1 parent d14ba44 commit 0462ad1
Show file tree
Hide file tree
Showing 4 changed files with 201 additions and 9 deletions.
97 changes: 97 additions & 0 deletions src/Parameter.php
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,103 @@ public function getFormat()
return $this->format;
}

/**
* From the allowable types, determine the type that a value matches.
*
* @param mixed $value Value for which a type should be determined.
*
* @return string|false Either the type that matches the specified value, or
* false if a determination isn't possible.
*/
public function determineType($value)
{
$type = $this->getType();

foreach ((array) $type as $t) {
if ($t == 'string'
&& (is_string($value) || (is_object($value) && method_exists($value, '__toString')))
) {
return 'string';
} elseif ($t == 'object' && (is_object($value) || $this->isAssociativeArray($value))) {
return 'object';
} elseif ($t == 'array' && $this->isIndexedArray($value)) {
return 'array';
} elseif ($t == 'integer' && is_integer($value)) {
return 'integer';
} elseif ($t == 'boolean' && is_bool($value)) {
return 'boolean';
} elseif ($t == 'number' && is_numeric($value)) {
return 'number';
} elseif ($t == 'numeric' && is_numeric($value)) {
return 'numeric';
} elseif ($t == 'null' && !$value) {
return 'null';
} elseif ($t == 'any') {
return 'any';
}
}

return false;
}

/**
* Determine whether or not a given value is an associative array or not.
*
* This is needed to help disambiguate an array that can be encoded as a
* JSON object (associative array) from one that can be encoded as a JSON
* array (non-associative array).
*
* Special case: an empty array is considered to be an associative array,
* vacuously.
*
* @param mixed $value
* The value to be checked.
*
* @return boolean
* TRUE if the value is an empty or associative array; FALSE if the value
* is not an array, or is an indexed array.
*/
private function isAssociativeArray($value) {
if (!is_array($value)) {
return false;
}

$is_empty = ($value === []);

// If array without any associative keys is strictly equal to the array,
// it's not an associative array.
$has_numeric_keys = (array_values($value) !== $value);

return $is_empty || $has_numeric_keys;
}

/**
* Determine whether or not a given value is an indexed array or not.
*
* This is needed to help disambiguate an array that can be encoded as a
* JSON object (associative array) from one that can be encoded as a JSON
* array (non-associative array).
*
* Special case: an empty array is considered to be an indexed array,
* vacuously.
*
* @param mixed $value
* The value to be checked.
*
* @return boolean
* TRUE if the value is an empty or indexed array; FALSE if the value is
* not an array, or is an associative array.
*/
private function isIndexedArray($value) {
if (!is_array($value)) {
return false;
}

$is_empty = ($value === []);

return $is_empty || !$this->isAssociativeArray($value);
}

/**
* Set the array of filters used by the parameter
*
Expand Down
27 changes: 19 additions & 8 deletions src/ResponseLocation/JsonLocation.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,22 +97,33 @@ public function visit(
$name = $param->getName();
$key = $param->getWireName();

$wholeValue = $this->json;

if (array_key_exists($key, $wholeValue)) {
$haveNestedElement = true;
$nestedElement = $wholeValue[$key];
} else {
$haveNestedElement = false;
$nestedElement = null;
}

$valueType = $param->determineType($wholeValue);

// Check if the result should be treated as a list
if ($param->getType() == 'array') {
if ($valueType === 'array') {
// Treat as javascript array
if ($name) {
if (!empty($name)) {
// name provided, store it under a key in the array
$subArray = isset($this->json[$key]) ? $this->json[$key] : null;
$result[$name] = $this->recurse($param, $subArray);
$result[$name] = $this->recurse($param, $nestedElement);
} else {
// top-level `array` or an empty name
$result = new Result(array_merge(
$result->toArray(),
$this->recurse($param, $this->json)
$this->recurse($param, $wholeValue)
));
}
} elseif (isset($this->json[$key])) {
$result[$name] = $this->recurse($param, $this->json[$key]);
} elseif ($haveNestedElement) {
$result[$name] = $this->recurse($param, $nestedElement);
}

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

$result = [];
$type = $param->getType();
$type = $param->determineType($value);

if ($type == 'array') {
$items = $param->getItems();
Expand Down
2 changes: 1 addition & 1 deletion src/SchemaValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ protected function recursiveProcess(
// Validate that the type is correct. If the type is string but an
// integer was passed, the class can be instructed to cast the integer
// to a string to pass validation. This is the default behavior.
if ($type && (!$type = $this->determineType($type, $value))) {
if ($type && (!$type = $param->determineType($value))) {
if ($this->castIntegerToStringType
&& $param->getType() == 'string'
&& is_integer($value)
Expand Down
84 changes: 84 additions & 0 deletions tests/ResponseLocation/JsonLocationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -578,4 +578,88 @@ public function testVisitsNestedArrayOfObjects()
];
$this->assertEquals($expected, $result->toArray());
}

public function polymorphicProvider()
{
return [
[
['null', 'string', 'object', 'array'],
'{"value": null}',
['value' => null]
],
[
['null', 'string', 'object', 'array'],
'{"value": "foo"}',
['value' => 'foo']
],
[
['null', 'string', 'object', 'array'],
'{"value": ["a", "b", "c"]}',
['value' => ['a', 'b', 'c']]
],
[
['null', 'string', 'array', 'object'],
'{"value": ["a", "b", "c"]}',
['value' => ['a', 'b', 'c']]
],
[
['null', 'string', 'object', 'array'],
'{"value": {"worked": true}}',
['value' => ['worked' => TRUE]]
],
[
['null', 'string', 'array', 'object'],
'{"value": {"worked": true}}',
['value' => ['worked' => TRUE]]
]
];
}


/**
* @dataProvider polymorphicProvider
* @group ResponseLocation
*/
public function testRecognizesPolymorphicReturnTypes($allowedTypes, $responseJson,
$expectedOutput)
{
$json = json_decode($responseJson);

$body = \GuzzleHttp\json_encode($json);
$response = new Response(200, ['Content-Type' => 'application/json'], $body);
$mock = new MockHandler([$response]);

$httpClient = new Client(['handler' => $mock]);

$description = new Description([
'operations' => [
'foo' => [
'uri' => 'http://httpbin.org',
'httpMethod' => 'GET',
'responseModel' => 'j'
]
],
'models' => [
'j' => [
'type' => 'object',
'location' => 'json',
'properties' => [
'value' => [
'type' => $allowedTypes,
'items' => [
'type' => 'any'
]
]
]
]
]
]);

$guzzle = new GuzzleClient($httpClient, $description);

/** @var ResultInterface $result */
$result = $guzzle->foo();

$this->assertEquals($expectedOutput, $result->toArray());
}
}

0 comments on commit 0462ad1

Please sign in to comment.