diff --git a/README.md b/README.md index 81405e6..883240f 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,8 @@ enum-bearing branch without requiring consumers to inspect the union shape: use Utopia\OpenAPI\Model\CompositeSchema; if ($schema instanceof CompositeSchema) { - $suggestedValues = $schema->openStringEnumBranch()?->enum ?? []; + $enumBranch = $schema->openStringEnumBranch(); + $suggestedValues = $enumBranch?->enum ?? []; } ``` diff --git a/src/Model/CompositeSchema.php b/src/Model/CompositeSchema.php index 78d3de8..b326543 100644 --- a/src/Model/CompositeSchema.php +++ b/src/Model/CompositeSchema.php @@ -6,10 +6,13 @@ final readonly class CompositeSchema extends Schema { + /** @var list */ + public array $schemas; + /** @param list $schemas */ public function __construct( public ?Composition $composition, - public array $schemas = [], + array $schemas = [], public ?Schema $not = null, public ?Discriminator $discriminator = null, ?string $title = null, @@ -24,6 +27,32 @@ public function __construct( mixed $example = null, array $extensions = [], ) { + $openEnumIndex = self::openStringEnumBranchIndex($composition, $schemas); + if ($openEnumIndex !== null) { + /** @var StringSchema $enumBranch */ + $enumBranch = $schemas[$openEnumIndex]; + $schemas[$openEnumIndex] = new StringSchema( + minLength: $enumBranch->minLength, + maxLength: $enumBranch->maxLength, + pattern: $enumBranch->pattern, + title: $enumBranch->title, + description: $enumBranch->description, + nullable: $enumBranch->nullable, + default: $enumBranch->default, + enum: $enumBranch->enum, + format: $enumBranch->format, + readOnly: $enumBranch->readOnly, + writeOnly: $enumBranch->writeOnly, + deprecated: $enumBranch->deprecated, + example: $enumBranch->example, + extensions: $enumBranch->extensions, + enumName: $enumBranch->enumName, + enumKeys: $enumBranch->enumKeys, + open: true, + ); + } + $this->schemas = $schemas; + parent::__construct($title, $description, $nullable, $default, $enum, $format, $readOnly, $writeOnly, $deprecated, $example, $extensions); } @@ -35,14 +64,22 @@ public function __construct( */ public function openStringEnumBranch(): ?StringSchema { - if ($this->composition !== Composition::ANY_OF) { + $index = self::openStringEnumBranchIndex($this->composition, $this->schemas); + + return $index === null ? null : $this->schemas[$index]; + } + + /** @param list $schemas */ + private static function openStringEnumBranchIndex(?Composition $composition, array $schemas): ?int + { + if ($composition !== Composition::ANY_OF) { return null; } - $enumBranch = null; + $enumBranchIndex = null; $hasOpenBranch = false; - foreach ($this->schemas as $schema) { + foreach ($schemas as $index => $schema) { if (! $schema instanceof StringSchema) { return null; } @@ -53,13 +90,13 @@ public function openStringEnumBranch(): ?StringSchema continue; } - if ($enumBranch !== null) { + if ($enumBranchIndex !== null) { return null; } - $enumBranch = $schema; + $enumBranchIndex = $index; } - return $hasOpenBranch ? $enumBranch : null; + return $hasOpenBranch ? $enumBranchIndex : null; } } diff --git a/src/Model/StringSchema.php b/src/Model/StringSchema.php index 906b554..c984677 100644 --- a/src/Model/StringSchema.php +++ b/src/Model/StringSchema.php @@ -6,6 +6,7 @@ final readonly class StringSchema extends Schema { + /** @param list $enumKeys */ public function __construct( public ?int $minLength = null, public ?int $maxLength = null, @@ -21,6 +22,9 @@ public function __construct( bool $deprecated = false, mixed $example = null, array $extensions = [], + public ?string $enumName = null, + public array $enumKeys = [], + public bool $open = false, ) { parent::__construct($title, $description, $nullable, $default, $enum, $format, $readOnly, $writeOnly, $deprecated, $example, $extensions); } diff --git a/src/Parser/Schema/Reader.php b/src/Parser/Schema/Reader.php index 08663c5..fdce161 100644 --- a/src/Parser/Schema/Reader.php +++ b/src/Parser/Schema/Reader.php @@ -116,6 +116,8 @@ public function read(mixed $raw, string $location): Schema default: $common['default'], enum: $common['enum'], readOnly: $common['readOnly'], writeOnly: $common['writeOnly'], deprecated: $common['deprecated'], example: $common['example'], extensions: $common['extensions'], + enumName: Value::optionalString($data, 'x-enum-name'), + enumKeys: isset($data['x-enum-keys']) ? Value::stringList($data['x-enum-keys'], "{$location}/x-enum-keys") : [], ), 'integer' => $this->integer($data, $location, $common), 'number' => $this->number($data, $location, $common), diff --git a/src/Parser/Value.php b/src/Parser/Value.php index fc3d7f5..075c4bc 100644 --- a/src/Parser/Value.php +++ b/src/Parser/Value.php @@ -35,6 +35,19 @@ public static function list(mixed $value, string $location): array return $value; } + /** @return list */ + public static function stringList(mixed $value, string $location): array + { + $values = self::list($value, $location); + foreach ($values as $index => $item) { + if (! is_string($item)) { + throw new InvalidSpecification("Expected string at {$location}/{$index}"); + } + } + + return $values; + } + /** @param array $data */ public static function requiredString(array $data, string $key, string $location): string { diff --git a/tests/Schema/ReaderTest.php b/tests/Schema/ReaderTest.php index 142db9d..fc7c75f 100644 --- a/tests/Schema/ReaderTest.php +++ b/tests/Schema/ReaderTest.php @@ -108,14 +108,24 @@ public function test_composition_and_not(): void public function test_open_string_enum_branch_is_exposed_regardless_of_branch_order(): void { $reader = $this->reader(Version::V3_0); - $enum = ['type' => 'string', 'enum' => ['network.requests', 'network.inbound']]; + $enum = [ + 'type' => 'string', + 'enum' => ['network.requests', 'network.inbound'], + 'x-enum-name' => 'UsageEventMetric', + 'x-enum-keys' => ['NetworkRequests', 'NetworkInbound'], + ]; $open = ['type' => 'string']; foreach ([[$enum, $open], [$open, $enum]] as $branches) { $schema = $reader->read(['anyOf' => $branches], '#/x'); self::assertInstanceOf(CompositeSchema::class, $schema); - self::assertSame(['network.requests', 'network.inbound'], $schema->openStringEnumBranch()?->enum); + $enumBranch = $schema->openStringEnumBranch(); + self::assertSame(['network.requests', 'network.inbound'], $enumBranch?->enum); + self::assertSame('UsageEventMetric', $enumBranch?->enumName); + self::assertSame(['NetworkRequests', 'NetworkInbound'], $enumBranch?->enumKeys); + self::assertTrue($enumBranch?->open); + self::assertFalse($schema->schemas[$enumBranch === $schema->schemas[0] ? 1 : 0]->open); } } diff --git a/tests/ValueTest.php b/tests/ValueTest.php index 87ea0ad..4f6f25e 100644 --- a/tests/ValueTest.php +++ b/tests/ValueTest.php @@ -52,6 +52,15 @@ public function test_list_rejects_maps_and_scalars(): void } } + public function test_string_list_rejects_non_string_items(): void + { + self::assertSame(['first', 'second'], Value::stringList(['first', 'second'], '#/x-enum-keys')); + + $this->expectException(InvalidSpecification::class); + $this->expectExceptionMessage('Expected string at #/x-enum-keys/1'); + Value::stringList(['first', 2], '#/x-enum-keys'); + } + public function test_required_string_names_the_missing_key(): void { self::assertSame('Pets', Value::requiredString(['title' => 'Pets'], 'title', '#/info'));