Skip to content
Merged
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
50 changes: 33 additions & 17 deletions src/OpenApiResponseValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@

namespace Studio\OpenApiContractTesting;

use const JSON_THROW_ON_ERROR;
use const PHP_INT_MAX;

use InvalidArgumentException;
use Opis\JsonSchema\Errors\ErrorFormatter;
use Opis\JsonSchema\Validator;
use stdClass;

use function array_is_list;
use function array_keys;
use function implode;
use function json_decode;
use function json_encode;
use function is_array;
use function sprintf;
use function str_ends_with;
use function strstr;
Expand Down Expand Up @@ -132,20 +132,8 @@ public function validate(
$schema = $content[$jsonContentType]['schema'];
$jsonSchema = OpenApiSchemaConverter::convert($schema, $version);

// opis/json-schema requires an object, so encode then decode
$schemaObject = json_decode(
(string) json_encode($jsonSchema, JSON_THROW_ON_ERROR),
false,
512,
JSON_THROW_ON_ERROR,
);

$dataObject = json_decode(
(string) json_encode($responseBody, JSON_THROW_ON_ERROR),
false,
512,
JSON_THROW_ON_ERROR,
);
$schemaObject = self::toObject($jsonSchema);
$dataObject = self::toObject($responseBody);

$resolvedMaxErrors = $this->maxErrors === 0 ? PHP_INT_MAX : $this->maxErrors;
$validator = new Validator(
Expand All @@ -171,6 +159,34 @@ public function validate(
return OpenApiValidationResult::failure($errors, $matchedPath);
}

/**
* Recursively convert PHP arrays to stdClass objects, matching the
* behaviour of json_decode(json_encode($data)) without the intermediate
* JSON string allocation.
*/
private static function toObject(mixed $value): mixed
{
if (!is_array($value)) {
return $value;
}

if ($value === [] || array_is_list($value)) {
/** @var list<mixed> $value */
foreach ($value as $i => $item) {
$value[$i] = self::toObject($item);
}

return $value;
}

$object = new stdClass();
foreach ($value as $key => $item) {
$object->{$key} = self::toObject($item);
}

return $object;
}

/**
* Find the first JSON-compatible content type from the response spec.
*
Expand Down
57 changes: 57 additions & 0 deletions tests/Unit/OpenApiResponseValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@

namespace Studio\OpenApiContractTesting\Tests\Unit;

use const JSON_THROW_ON_ERROR;

use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
use Studio\OpenApiContractTesting\OpenApiResponseValidator;
use Studio\OpenApiContractTesting\OpenApiSpecLoader;

use function array_map;
use function count;
use function json_encode;
use function range;

class OpenApiResponseValidatorTest extends TestCase
Expand All @@ -32,6 +37,42 @@ protected function tearDown(): void
parent::tearDown();
}

// ========================================
// toObject equivalence tests
// ========================================

/**
* @return iterable<string, array{mixed}>
*/
public static function provideTo_object_matches_json_roundtripCases(): iterable
{
yield 'null' => [null];
yield 'string' => ['hello'];
yield 'integer' => [42];
yield 'float' => [3.14];
yield 'boolean true' => [true];
yield 'boolean false' => [false];
yield 'empty array' => [[]];
yield 'sequential array' => [[1, 2, 3]];
yield 'associative array' => [['key' => 'value', 'num' => 1]];
yield 'nested associative' => [['a' => ['b' => ['c' => 'deep']]]];
yield 'list of objects' => [[['id' => 1, 'name' => 'a'], ['id' => 2, 'name' => 'b']]];
yield 'non-sequential int keys' => [[1 => 'a', 3 => 'b']];
yield 'mixed nested' => [
[
'users' => [
['id' => 1, 'tags' => ['admin', 'user'], 'meta' => ['active' => true]],
],
'total' => 1,
'filters' => [],
],
];
yield 'numeric string keys' => [['200' => ['description' => 'OK']]];
yield 'deeply nested list' => [[[['a']]]];
yield 'null in array' => [[null, 'a', null]];
yield 'empty nested object' => [['data' => []]];
}

// ========================================
// OAS 3.0 tests
// ========================================
Expand Down Expand Up @@ -744,4 +785,20 @@ public function v30_strip_prefixes_applied(): void
$this->assertTrue($result->isValid());
$this->assertSame('/v1/pets', $result->matchedPath());
}

#[Test]
#[DataProvider('provideTo_object_matches_json_roundtripCases')]
public function to_object_matches_json_roundtrip(mixed $input): void
{
$method = new ReflectionMethod(OpenApiResponseValidator::class, 'toObject');

$actual = $method->invoke(null, $input);

// Re-encode both to JSON to compare structural equivalence
// without relying on object identity (assertSame fails on stdClass).
$expectedJson = json_encode($input, JSON_THROW_ON_ERROR);
$actualJson = (string) json_encode($actual, JSON_THROW_ON_ERROR);

$this->assertSame($expectedJson, $actualJson);
}
}