diff --git a/src/OpenApiResponseValidator.php b/src/OpenApiResponseValidator.php index e06e126..3c7c3e7 100644 --- a/src/OpenApiResponseValidator.php +++ b/src/OpenApiResponseValidator.php @@ -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; @@ -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( @@ -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 $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. * diff --git a/tests/Unit/OpenApiResponseValidatorTest.php b/tests/Unit/OpenApiResponseValidatorTest.php index 3fbfc11..186b746 100644 --- a/tests/Unit/OpenApiResponseValidatorTest.php +++ b/tests/Unit/OpenApiResponseValidatorTest.php @@ -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 @@ -32,6 +37,42 @@ protected function tearDown(): void parent::tearDown(); } + // ======================================== + // toObject equivalence tests + // ======================================== + + /** + * @return iterable + */ + 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 // ======================================== @@ -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); + } }