diff --git a/src/Model/CompositeSchema.php b/src/Model/CompositeSchema.php index b326543..d23cbd5 100644 --- a/src/Model/CompositeSchema.php +++ b/src/Model/CompositeSchema.php @@ -27,7 +27,7 @@ public function __construct( mixed $example = null, array $extensions = [], ) { - $openEnumIndex = self::openStringEnumBranchIndex($composition, $schemas); + $openEnumIndex = self::openStringEnumBranchIndex($composition, $schemas, $not, $enum); if ($openEnumIndex !== null) { /** @var StringSchema $enumBranch */ $enumBranch = $schemas[$openEnumIndex]; @@ -64,15 +64,18 @@ enumKeys: $enumBranch->enumKeys, */ public function openStringEnumBranch(): ?StringSchema { - $index = self::openStringEnumBranchIndex($this->composition, $this->schemas); + $index = self::openStringEnumBranchIndex($this->composition, $this->schemas, $this->not, $this->enum); return $index === null ? null : $this->schemas[$index]; } - /** @param list $schemas */ - private static function openStringEnumBranchIndex(?Composition $composition, array $schemas): ?int + /** + * @param list $schemas + * @param list $enum + */ + private static function openStringEnumBranchIndex(?Composition $composition, array $schemas, ?Schema $not, array $enum): ?int { - if ($composition !== Composition::ANY_OF) { + if ($composition !== Composition::ANY_OF || $not !== null || $enum !== []) { return null; } @@ -85,6 +88,14 @@ private static function openStringEnumBranchIndex(?Composition $composition, arr } if ($schema->enum === []) { + if ( + $schema->minLength !== null + || $schema->maxLength !== null + || $schema->pattern !== null + || $schema->format !== null + ) { + return null; + } $hasOpenBranch = true; continue; diff --git a/src/Parser/Schema/Reader.php b/src/Parser/Schema/Reader.php index fdce161..e278e01 100644 --- a/src/Parser/Schema/Reader.php +++ b/src/Parser/Schema/Reader.php @@ -33,6 +33,7 @@ 'type', 'format', 'items', 'default', 'enum', 'maximum', 'exclusiveMaximum', 'minimum', 'exclusiveMinimum', 'maxLength', 'minLength', 'pattern', 'maxItems', 'minItems', 'uniqueItems', 'multipleOf', 'description', + 'x-enum-name', 'x-enum-keys', ]; public function __construct(private Dialect $dialect) {} @@ -117,7 +118,7 @@ public function read(mixed $raw, string $location): Schema 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") : [], + enumKeys: $this->enumKeys($data, $location, $common['enum']), ), 'integer' => $this->integer($data, $location, $common), 'number' => $this->number($data, $location, $common), @@ -149,6 +150,25 @@ public function readParameterFields(array $data, string $location): Schema return $this->read(array_intersect_key($data, array_flip(self::PARAMETER_FIELDS)), $location); } + /** + * @param array $data + * @param list $enum + * @return list + */ + private function enumKeys(array $data, string $location, array $enum): array + { + if (! isset($data['x-enum-keys'])) { + return []; + } + + $keys = Value::stringList($data['x-enum-keys'], "{$location}/x-enum-keys"); + if (count($keys) !== count($enum)) { + throw new InvalidSpecification("Expected x-enum-keys to match enum length at {$location}"); + } + + return $keys; + } + /** * @param array $data * @return array diff --git a/tests/Schema/ReaderTest.php b/tests/Schema/ReaderTest.php index fc7c75f..9539580 100644 --- a/tests/Schema/ReaderTest.php +++ b/tests/Schema/ReaderTest.php @@ -129,6 +129,32 @@ public function test_open_string_enum_branch_is_exposed_regardless_of_branch_ord } } + public function test_enum_metadata_is_preserved_for_openapi_two_inline_parameters(): void + { + $schema = $this->reader(Version::V2)->readParameterFields([ + 'type' => 'string', + 'enum' => ['network.requests'], + 'x-enum-name' => 'UsageEventMetric', + 'x-enum-keys' => ['NetworkRequests'], + ], '#/parameters/metric'); + + self::assertInstanceOf(StringSchema::class, $schema); + self::assertSame('UsageEventMetric', $schema->enumName); + self::assertSame(['NetworkRequests'], $schema->enumKeys); + } + + public function test_enum_keys_must_match_enum_length(): void + { + $this->expectException(InvalidSpecification::class); + $this->expectExceptionMessage('Expected x-enum-keys to match enum length at #/x'); + + $this->reader(Version::V3_0)->read([ + 'type' => 'string', + 'enum' => ['first', 'second'], + 'x-enum-keys' => ['First'], + ], '#/x'); + } + public function test_open_string_enum_requires_any_of(): void { $reader = $this->reader(Version::V3_0); @@ -175,6 +201,32 @@ public function test_open_string_enum_requires_one_enum_and_an_open_string_branc } } + public function test_open_string_enum_requires_an_unrestricted_string_branch(): void + { + $reader = $this->reader(Version::V3_0); + $enum = ['type' => 'string', 'enum' => ['known']]; + $constraints = [ + ['minLength' => 1], + ['maxLength' => 10], + ['pattern' => '^known$'], + ['format' => 'uuid'], + ]; + + foreach ($constraints as $constraint) { + $schema = $reader->read(['anyOf' => [$enum, ['type' => 'string', ...$constraint]]], '#/x'); + + self::assertInstanceOf(CompositeSchema::class, $schema); + self::assertNull($schema->openStringEnumBranch()); + } + + foreach ([['enum' => ['known']], ['not' => ['type' => 'string', 'enum' => ['blocked']]]] as $constraint) { + $schema = $reader->read(['anyOf' => [$enum, ['type' => 'string']], ...$constraint], '#/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);