From fa27bb8af9c9eb350815d029760e5a8e9d2e1e97 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 20 Aug 2026 19:02:58 +0530 Subject: [PATCH] feat: expose open string enum branches An anyOf can combine a string enum with an enum-free string branch to document common values without closing the accepted set. Consumers currently have to repeat that structural interpretation themselves.\n\nExpose the enum-bearing branch from CompositeSchema when the union has exactly that shape. Keep oneOf, mixed-type unions, and unions with multiple enums unchanged. --- README.md | 15 +++++++++ src/Model/CompositeSchema.php | 36 +++++++++++++++++++++ tests/Schema/ReaderTest.php | 60 +++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+) diff --git a/README.md b/README.md index 3720894..81405e6 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,21 @@ if ($schema instanceof ReferenceSchema) { This makes valid recursive schemas safe to parse. +An `anyOf` that combines one string enum with another string branch documents +suggested values without closing the set. The canonical composite exposes the +enum-bearing branch without requiring consumers to inspect the union shape: + +```php +use Utopia\OpenAPI\Model\CompositeSchema; + +if ($schema instanceof CompositeSchema) { + $suggestedValues = $schema->openStringEnumBranch()?->enum ?? []; +} +``` + +`oneOf`, unions with non-string branches, and unions with multiple enum branches +are not treated as open string enums. + ### Parameters and request bodies Path-level parameters are inherited by operations. An operation-level parameter with the same case-sensitive `name` and `in` value replaces the inherited parameter. diff --git a/src/Model/CompositeSchema.php b/src/Model/CompositeSchema.php index 412f4c6..78d3de8 100644 --- a/src/Model/CompositeSchema.php +++ b/src/Model/CompositeSchema.php @@ -26,4 +26,40 @@ public function __construct( ) { parent::__construct($title, $description, $nullable, $default, $enum, $format, $readOnly, $writeOnly, $deprecated, $example, $extensions); } + + /** + * Return the documented values from an open string enum. + * + * An open string enum uses anyOf to combine one string enum with one or + * more string branches that accept values outside the documented set. + */ + public function openStringEnumBranch(): ?StringSchema + { + if ($this->composition !== Composition::ANY_OF) { + return null; + } + + $enumBranch = null; + $hasOpenBranch = false; + + foreach ($this->schemas as $schema) { + if (! $schema instanceof StringSchema) { + return null; + } + + if ($schema->enum === []) { + $hasOpenBranch = true; + + continue; + } + + if ($enumBranch !== null) { + return null; + } + + $enumBranch = $schema; + } + + return $hasOpenBranch ? $enumBranch : null; + } } diff --git a/tests/Schema/ReaderTest.php b/tests/Schema/ReaderTest.php index 128e9e7..142db9d 100644 --- a/tests/Schema/ReaderTest.php +++ b/tests/Schema/ReaderTest.php @@ -105,6 +105,66 @@ public function test_composition_and_not(): void self::assertInstanceOf(StringSchema::class, $negated->not); } + 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']]; + $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); + } + } + + public function test_open_string_enum_requires_any_of(): void + { + $reader = $this->reader(Version::V3_0); + $branches = [ + ['type' => 'string', 'enum' => ['known']], + ['type' => 'string'], + ]; + + foreach ([Composition::ONE_OF, Composition::ALL_OF] as $composition) { + $schema = $reader->read([$composition->value => $branches], '#/x'); + + self::assertInstanceOf(CompositeSchema::class, $schema); + self::assertNull($schema->openStringEnumBranch()); + } + } + + public function test_open_string_enum_requires_one_enum_and_an_open_string_branch(): void + { + $reader = $this->reader(Version::V3_0); + $invalidUnions = [ + [['type' => 'string', 'enum' => ['known']]], + [['type' => 'string'], ['type' => 'string']], + [ + ['type' => 'string', 'enum' => ['first']], + ['type' => 'string', 'enum' => ['second']], + ['type' => 'string'], + ], + [ + ['type' => 'integer', 'enum' => [1]], + ['type' => 'string'], + ], + [ + ['type' => 'string', 'enum' => ['known']], + ['type' => 'string'], + ['type' => 'integer'], + ], + ]; + + foreach ($invalidUnions as $branches) { + $schema = $reader->read(['anyOf' => $branches], '#/x'); + + self::assertInstanceOf(CompositeSchema::class, $schema); + self::assertNull($schema->openStringEnumBranch()); + } + } + public function test_discriminator_is_read_from_both_the_string_and_object_forms(): void { $reader = $this->reader(Version::V3_0);