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
79 changes: 33 additions & 46 deletions src/OpenApiSchemaConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
namespace Studio\OpenApiContractTesting;

use function array_is_list;
use function array_map;
use function is_array;
use function is_string;

Expand Down Expand Up @@ -44,92 +43,92 @@ final class OpenApiSchemaConverter
*/
public static function convert(array $schema, OpenApiVersion $version = OpenApiVersion::V3_0): array
{
return self::convertRecursive($schema, $version);
self::convertInPlace($schema, $version);

return $schema;
}

/**
* @param array<string, mixed> $schema
*
* @return array<string, mixed>
*/
private static function convertRecursive(array $schema, OpenApiVersion $version): array
private static function convertInPlace(array &$schema, OpenApiVersion $version): void
{
if ($version === OpenApiVersion::V3_0) {
$schema = self::handleNullable($schema);
$schema = self::removeKeys($schema, self::OPENAPI_3_0_KEYS);
self::handleNullable($schema);
self::removeKeys($schema, self::OPENAPI_3_0_KEYS);
} else {
$schema = self::handlePrefixItems($schema);
$schema = self::removeKeys($schema, self::DRAFT_2020_12_KEYS);
self::handlePrefixItems($schema);
self::removeKeys($schema, self::DRAFT_2020_12_KEYS);
}

$schema = self::removeKeys($schema, self::OPENAPI_COMMON_KEYS);
self::removeKeys($schema, self::OPENAPI_COMMON_KEYS);

if (isset($schema['properties']) && is_array($schema['properties'])) {
foreach ($schema['properties'] as $key => $property) {
foreach ($schema['properties'] as &$property) {
if (is_array($property)) {
$schema['properties'][$key] = self::convertRecursive($property, $version);
self::convertInPlace($property, $version);
}
}
unset($property);
}

if (isset($schema['items']) && is_array($schema['items'])) {
// items can be an array (tuple from prefixItems conversion) or an object schema
if (array_is_list($schema['items'])) {
$schema['items'] = array_map(
static fn(mixed $item): mixed => is_array($item) ? self::convertRecursive($item, $version) : $item,
$schema['items'],
);
foreach ($schema['items'] as &$item) {
if (is_array($item)) {
self::convertInPlace($item, $version);
}
}
unset($item);
} else {
$schema['items'] = self::convertRecursive($schema['items'], $version);
self::convertInPlace($schema['items'], $version);
}
}

foreach (['allOf', 'oneOf', 'anyOf'] as $combiner) {
if (isset($schema[$combiner]) && is_array($schema[$combiner])) {
$schema[$combiner] = array_map(
static fn(mixed $item): mixed => is_array($item) ? self::convertRecursive($item, $version) : $item,
$schema[$combiner],
);
foreach ($schema[$combiner] as &$item) {
if (is_array($item)) {
self::convertInPlace($item, $version);
}
}
unset($item);
}
}

if (isset($schema['additionalProperties']) && is_array($schema['additionalProperties'])) {
$schema['additionalProperties'] = self::convertRecursive($schema['additionalProperties'], $version);
self::convertInPlace($schema['additionalProperties'], $version);
}

if (isset($schema['not']) && is_array($schema['not'])) {
$schema['not'] = self::convertRecursive($schema['not'], $version);
self::convertInPlace($schema['not'], $version);
}

return $schema;
}

/**
* Convert OpenAPI 3.0 nullable to JSON Schema compatible type.
*
* @param array<string, mixed> $schema
*
* @return array<string, mixed>
*/
private static function handleNullable(array $schema): array
private static function handleNullable(array &$schema): void
{
if (!isset($schema['nullable']) || $schema['nullable'] !== true) {
return $schema;
return;
}

unset($schema['nullable']);

if (isset($schema['type']) && is_string($schema['type'])) {
$schema['type'] = [$schema['type'], 'null'];

return $schema;
return;
}

foreach (['oneOf', 'anyOf'] as $combiner) {
if (isset($schema[$combiner]) && is_array($schema[$combiner])) {
$schema[$combiner][] = ['type' => 'null'];

return $schema;
return;
}
}

Expand All @@ -140,42 +139,30 @@ private static function handleNullable(array $schema): array
['allOf' => $allOf],
['type' => 'null'],
];

return $schema;
}

return $schema;
}

/**
* Convert Draft 2020-12 prefixItems to Draft 07 items array (tuple validation).
*
* @param array<string, mixed> $schema
*
* @return array<string, mixed>
*/
private static function handlePrefixItems(array $schema): array
private static function handlePrefixItems(array &$schema): void
{
if (isset($schema['prefixItems']) && is_array($schema['prefixItems'])) {
$schema['items'] = $schema['prefixItems'];
unset($schema['prefixItems']);
}

return $schema;
}

/**
* @param array<string, mixed> $schema
* @param string[] $keys
*
* @return array<string, mixed>
*/
private static function removeKeys(array $schema, array $keys): array
private static function removeKeys(array &$schema, array $keys): void
{
foreach ($keys as $key) {
unset($schema[$key]);
}

return $schema;
}
}
56 changes: 56 additions & 0 deletions tests/Unit/OpenApiSchemaConverterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -308,4 +308,60 @@ public function v31_read_only_write_only_preserved(): void
$this->assertTrue($result['readOnly']);
$this->assertFalse($result['writeOnly']);
}

// ========================================
// Input immutability tests
// ========================================

#[Test]
public function convert_does_not_mutate_input_schema(): void
{
$schema = [
'type' => 'object',
'nullable' => true,
'example' => 'test',
'properties' => [
'name' => [
'type' => 'string',
'nullable' => true,
'example' => 'John',
],
'tags' => [
'type' => 'array',
'items' => [
'type' => 'string',
'deprecated' => true,
],
],
],
];
$original = $schema;

OpenApiSchemaConverter::convert($schema, OpenApiVersion::V3_0);

$this->assertSame($original, $schema);
}

#[Test]
public function convert_does_not_mutate_input_schema_v31(): void
{
$schema = [
'type' => 'object',
'prefixItems' => [
['type' => 'string'],
],
'examples' => [['key' => 'value']],
'properties' => [
'data' => [
'type' => 'object',
'$dynamicRef' => '#meta',
],
],
];
$original = $schema;

OpenApiSchemaConverter::convert($schema, OpenApiVersion::V3_1);

$this->assertSame($original, $schema);
}
}