Skip to content

Commit 6dcfb2c

Browse files
authored
Merge pull request #34 from studio-design/refactor/remove-json-roundtrip
refactor: replace JSON roundtrip with direct array-to-object conversion
2 parents a836231 + e9fc6cb commit 6dcfb2c

2 files changed

Lines changed: 90 additions & 17 deletions

File tree

src/OpenApiResponseValidator.php

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@
44

55
namespace Studio\OpenApiContractTesting;
66

7-
use const JSON_THROW_ON_ERROR;
87
use const PHP_INT_MAX;
98

109
use InvalidArgumentException;
1110
use Opis\JsonSchema\Errors\ErrorFormatter;
1211
use Opis\JsonSchema\Validator;
12+
use stdClass;
1313

14+
use function array_is_list;
1415
use function array_keys;
1516
use function implode;
16-
use function json_decode;
17-
use function json_encode;
17+
use function is_array;
1818
use function sprintf;
1919
use function str_ends_with;
2020
use function strstr;
@@ -132,20 +132,8 @@ public function validate(
132132
$schema = $content[$jsonContentType]['schema'];
133133
$jsonSchema = OpenApiSchemaConverter::convert($schema, $version);
134134

135-
// opis/json-schema requires an object, so encode then decode
136-
$schemaObject = json_decode(
137-
(string) json_encode($jsonSchema, JSON_THROW_ON_ERROR),
138-
false,
139-
512,
140-
JSON_THROW_ON_ERROR,
141-
);
142-
143-
$dataObject = json_decode(
144-
(string) json_encode($responseBody, JSON_THROW_ON_ERROR),
145-
false,
146-
512,
147-
JSON_THROW_ON_ERROR,
148-
);
135+
$schemaObject = self::toObject($jsonSchema);
136+
$dataObject = self::toObject($responseBody);
149137

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

162+
/**
163+
* Recursively convert PHP arrays to stdClass objects, matching the
164+
* behaviour of json_decode(json_encode($data)) without the intermediate
165+
* JSON string allocation.
166+
*/
167+
private static function toObject(mixed $value): mixed
168+
{
169+
if (!is_array($value)) {
170+
return $value;
171+
}
172+
173+
if ($value === [] || array_is_list($value)) {
174+
/** @var list<mixed> $value */
175+
foreach ($value as $i => $item) {
176+
$value[$i] = self::toObject($item);
177+
}
178+
179+
return $value;
180+
}
181+
182+
$object = new stdClass();
183+
foreach ($value as $key => $item) {
184+
$object->{$key} = self::toObject($item);
185+
}
186+
187+
return $object;
188+
}
189+
174190
/**
175191
* Find the first JSON-compatible content type from the response spec.
176192
*

tests/Unit/OpenApiResponseValidatorTest.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,19 @@
44

55
namespace Studio\OpenApiContractTesting\Tests\Unit;
66

7+
use const JSON_THROW_ON_ERROR;
8+
79
use InvalidArgumentException;
10+
use PHPUnit\Framework\Attributes\DataProvider;
811
use PHPUnit\Framework\Attributes\Test;
912
use PHPUnit\Framework\TestCase;
13+
use ReflectionMethod;
1014
use Studio\OpenApiContractTesting\OpenApiResponseValidator;
1115
use Studio\OpenApiContractTesting\OpenApiSpecLoader;
1216

1317
use function array_map;
1418
use function count;
19+
use function json_encode;
1520
use function range;
1621

1722
class OpenApiResponseValidatorTest extends TestCase
@@ -32,6 +37,42 @@ protected function tearDown(): void
3237
parent::tearDown();
3338
}
3439

40+
// ========================================
41+
// toObject equivalence tests
42+
// ========================================
43+
44+
/**
45+
* @return iterable<string, array{mixed}>
46+
*/
47+
public static function provideTo_object_matches_json_roundtripCases(): iterable
48+
{
49+
yield 'null' => [null];
50+
yield 'string' => ['hello'];
51+
yield 'integer' => [42];
52+
yield 'float' => [3.14];
53+
yield 'boolean true' => [true];
54+
yield 'boolean false' => [false];
55+
yield 'empty array' => [[]];
56+
yield 'sequential array' => [[1, 2, 3]];
57+
yield 'associative array' => [['key' => 'value', 'num' => 1]];
58+
yield 'nested associative' => [['a' => ['b' => ['c' => 'deep']]]];
59+
yield 'list of objects' => [[['id' => 1, 'name' => 'a'], ['id' => 2, 'name' => 'b']]];
60+
yield 'non-sequential int keys' => [[1 => 'a', 3 => 'b']];
61+
yield 'mixed nested' => [
62+
[
63+
'users' => [
64+
['id' => 1, 'tags' => ['admin', 'user'], 'meta' => ['active' => true]],
65+
],
66+
'total' => 1,
67+
'filters' => [],
68+
],
69+
];
70+
yield 'numeric string keys' => [['200' => ['description' => 'OK']]];
71+
yield 'deeply nested list' => [[[['a']]]];
72+
yield 'null in array' => [[null, 'a', null]];
73+
yield 'empty nested object' => [['data' => []]];
74+
}
75+
3576
// ========================================
3677
// OAS 3.0 tests
3778
// ========================================
@@ -744,4 +785,20 @@ public function v30_strip_prefixes_applied(): void
744785
$this->assertTrue($result->isValid());
745786
$this->assertSame('/v1/pets', $result->matchedPath());
746787
}
788+
789+
#[Test]
790+
#[DataProvider('provideTo_object_matches_json_roundtripCases')]
791+
public function to_object_matches_json_roundtrip(mixed $input): void
792+
{
793+
$method = new ReflectionMethod(OpenApiResponseValidator::class, 'toObject');
794+
795+
$actual = $method->invoke(null, $input);
796+
797+
// Re-encode both to JSON to compare structural equivalence
798+
// without relying on object identity (assertSame fails on stdClass).
799+
$expectedJson = json_encode($input, JSON_THROW_ON_ERROR);
800+
$actualJson = (string) json_encode($actual, JSON_THROW_ON_ERROR);
801+
802+
$this->assertSame($expectedJson, $actualJson);
803+
}
747804
}

0 commit comments

Comments
 (0)