From f975f1ba8a14fa8bdc772b90800d1c677726d5cf Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 21 Aug 2026 09:26:03 +0530 Subject: [PATCH 1/2] (feat): map OAS 3.1 annotated enumerations onto StringSchema --- CONTEXT.md | 4 +- README.md | 40 ++++- src/Model/CompositeSchema.php | 275 +++++++++++++++++++++++++++++-- src/Parser/Schema/Reader.php | 28 +--- tests/Schema/ReaderTest.php | 294 +++++++++++++++++++++++++++++----- tests/ValueTest.php | 6 +- 6 files changed, 552 insertions(+), 95 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index bcd80fb..837744a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -28,4 +28,6 @@ Terms used across this library. Use these words in code, tests, and commits. **Annotations** — the fields every schema kind shares: title, description, nullable, default, enum, format, readOnly, writeOnly, deprecated, example, extensions. -**Extension** — any `x-`-prefixed key. Captured on every model that can carry one; never interpreted. +**Annotated enumeration** — an OAS 3.1 `oneOf` or `anyOf` whose members are string `const` (or one-element `enum`) schemas, optionally with `title`/`description`. Mapped onto `StringSchema` (`enum`, `enumName` from the composite title, `enumKeys` from branch titles, `open` when composed with an unconstrained string). Exposed on `CompositeSchema` as `stringEnum()` without collapsing the union tree. + +**Extension** — any `x-`-prefixed key. Captured on every model that can carry one; never interpreted. Enum type and value names come from `title`, not from `x-enum-name` / `x-enum-keys`. diff --git a/README.md b/README.md index 883240f..af5ef0e 100644 --- a/README.md +++ b/README.md @@ -158,21 +158,45 @@ 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: +OpenAPI 3.1 annotated enumerations (`oneOf` or `anyOf` of `const` + `title`) +are mapped onto `StringSchema` fields. The type name is the composite `title`. +Value names are each branch’s `title`. + +```yaml +title: WebhookEvent +oneOf: + - const: user.created + title: UserCreated + - const: user.updated + title: UserUpdated +``` ```php use Utopia\OpenAPI\Model\CompositeSchema; +use Utopia\OpenAPI\Model\StringSchema; if ($schema instanceof CompositeSchema) { - $enumBranch = $schema->openStringEnumBranch(); - $suggestedValues = $enumBranch?->enum ?? []; + $enum = $schema->stringEnum(); + $values = $enum?->enum ?? []; + $keys = $enum?->enumKeys ?? []; + $name = $enum?->enumName; } ``` -`oneOf`, unions with non-string branches, and unions with multiple enum branches -are not treated as open string enums. +The composite tree is preserved. `stringEnum()` returns a synthesized +`StringSchema` (`enum`, `enumName`, `enumKeys`, `open`) so consumers do not +walk the union. + +An `anyOf` that adds an unconstrained `type: string` branch documents +suggested values without closing the set (`open: true`). That includes a +legacy multi-value string enum next to `type: string`, a flattened list of +consts plus `type: string`, and a nested annotated `oneOf` plus `type: string`. +`openStringEnumBranch()` returns the same `StringSchema` only when `open` is +true. + +`allOf`, unions with `$ref` members, and unions with multiple multi-value +enum branches are not treated as string enums. Mixed const and object, numeric +const, or multi-value enum branches throw `InvalidSpecification`. ### Parameters and request bodies @@ -291,7 +315,7 @@ The core parser supports JSON strings and decoded PHP arrays. The following capa - Complete OpenAPI conformance validation - Serialization or textual round trips - Callbacks, links, and OpenAPI 3.1 webhooks -- Interpretation of vendor extensions +- Interpretation of vendor extensions (`x-enum-name` and `x-enum-keys` are retained as opaque extensions) - Swagger 1.x A missing `operationId` is currently accepted and represented as an empty string. diff --git a/src/Model/CompositeSchema.php b/src/Model/CompositeSchema.php index d23cbd5..f454c49 100644 --- a/src/Model/CompositeSchema.php +++ b/src/Model/CompositeSchema.php @@ -4,11 +4,15 @@ namespace Utopia\OpenAPI\Model; +use Utopia\OpenAPI\Exception\InvalidSpecification; + final readonly class CompositeSchema extends Schema { /** @var list */ public array $schemas; + private ?StringSchema $stringEnum; + /** @param list $schemas */ public function __construct( public ?Composition $composition, @@ -26,8 +30,9 @@ public function __construct( bool $deprecated = false, mixed $example = null, array $extensions = [], + string $location = '#', ) { - $openEnumIndex = self::openStringEnumBranchIndex($composition, $schemas, $not, $enum); + $openEnumIndex = self::legacyOpenStringEnumBranchIndex($composition, $schemas, $not, $enum, $discriminator); if ($openEnumIndex !== null) { /** @var StringSchema $enumBranch */ $enumBranch = $schemas[$openEnumIndex]; @@ -46,7 +51,7 @@ enum: $enumBranch->enum, deprecated: $enumBranch->deprecated, example: $enumBranch->example, extensions: $enumBranch->extensions, - enumName: $enumBranch->enumName, + enumName: $enumBranch->enumName ?? $title, enumKeys: $enumBranch->enumKeys, open: true, ); @@ -54,28 +59,52 @@ enumKeys: $enumBranch->enumKeys, $this->schemas = $schemas; parent::__construct($title, $description, $nullable, $default, $enum, $format, $readOnly, $writeOnly, $deprecated, $example, $extensions); + + $this->stringEnum = self::detectStringEnum( + $composition, + $this->schemas, + $not, + $enum, + $discriminator, + $title, + $description, + $nullable, + $default, + $extensions, + $location, + ); + } + + /** + * Return the documented string enum, whether closed, open, or annotated. + * + * Closed annotated oneOf/anyOf of string consts synthesize a StringSchema + * with open: false. Open unions (legacy multi-value enum or annotated consts + * plus an unconstrained string) return a StringSchema with open: true. + */ + public function stringEnum(): ?StringSchema + { + return $this->stringEnum; } /** * 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. + * An open string enum uses anyOf to combine documented string values with + * one or more string branches that accept values outside that set. */ public function openStringEnumBranch(): ?StringSchema { - $index = self::openStringEnumBranchIndex($this->composition, $this->schemas, $this->not, $this->enum); - - return $index === null ? null : $this->schemas[$index]; + return $this->stringEnum?->open === true ? $this->stringEnum : null; } /** * @param list $schemas * @param list $enum */ - private static function openStringEnumBranchIndex(?Composition $composition, array $schemas, ?Schema $not, array $enum): ?int + private static function legacyOpenStringEnumBranchIndex(?Composition $composition, array $schemas, ?Schema $not, array $enum, ?Discriminator $discriminator): ?int { - if ($composition !== Composition::ANY_OF || $not !== null || $enum !== []) { + if ($composition !== Composition::ANY_OF || $not !== null || $enum !== [] || $discriminator !== null) { return null; } @@ -88,12 +117,7 @@ private static function openStringEnumBranchIndex(?Composition $composition, arr } if ($schema->enum === []) { - if ( - $schema->minLength !== null - || $schema->maxLength !== null - || $schema->pattern !== null - || $schema->format !== null - ) { + if (! self::isUnconstrainedString($schema)) { return null; } $hasOpenBranch = true; @@ -101,7 +125,7 @@ private static function openStringEnumBranchIndex(?Composition $composition, arr continue; } - if ($enumBranchIndex !== null) { + if ($enumBranchIndex !== null || count($schema->enum) < 2) { return null; } @@ -110,4 +134,223 @@ private static function openStringEnumBranchIndex(?Composition $composition, arr return $hasOpenBranch ? $enumBranchIndex : null; } + + /** + * @param list $schemas + * @param list $enum + * @param array $extensions + */ + private static function detectStringEnum( + ?Composition $composition, + array $schemas, + ?Schema $not, + array $enum, + ?Discriminator $discriminator, + ?string $title, + string $description, + bool $nullable, + mixed $default, + array $extensions, + string $location, + ): ?StringSchema { + if ( + ($composition !== Composition::ONE_OF && $composition !== Composition::ANY_OF) + || $not !== null + || $enum !== [] + || $discriminator !== null + || $schemas === [] + ) { + return null; + } + + /** @var list $consts */ + $consts = []; + /** @var list $nested */ + $nested = []; + /** @var list $multiValue */ + $multiValue = []; + $unconstrained = 0; + $hasReference = false; + $hasNumericConst = false; + $hasNonString = false; + $hasConstrainedString = false; + + foreach ($schemas as $schema) { + if ($schema instanceof ReferenceSchema) { + $hasReference = true; + + continue; + } + + if (self::isUnconstrainedString($schema)) { + $unconstrained++; + + continue; + } + + if ($schema instanceof StringSchema && self::isConstrainedString($schema) && $schema->enum === []) { + $hasConstrainedString = true; + + continue; + } + + $nestedEnum = $schema instanceof self ? $schema->stringEnum() : null; + if ($nestedEnum instanceof StringSchema && $nestedEnum->open === false && $nestedEnum->enum !== []) { + $nested[] = $nestedEnum; + + continue; + } + + $constValue = self::stringConstValue($schema); + if ($constValue !== null) { + $consts[] = ['value' => $constValue, 'title' => $schema->title]; + + continue; + } + + if ($schema instanceof StringSchema && count($schema->enum) > 1) { + $multiValue[] = $schema; + + continue; + } + + if (self::isNonStringConst($schema)) { + $hasNumericConst = true; + + continue; + } + + $hasNonString = true; + } + + $hasAnnotatedValues = $consts !== [] || $nested !== []; + if ($hasReference || $hasConstrainedString) { + return null; + } + if ($hasAnnotatedValues && ($hasNumericConst || $hasNonString || $multiValue !== [])) { + throw new InvalidSpecification("Invalid annotated string enumeration at {$location}"); + } + if ($hasAnnotatedValues && $consts !== [] && $nested !== []) { + throw new InvalidSpecification("Invalid annotated string enumeration at {$location}"); + } + + if ($unconstrained > 0) { + if ($composition !== Composition::ANY_OF) { + return null; + } + if (count($multiValue) === 1 && $consts === [] && $nested === [] && ! $hasNumericConst && ! $hasNonString) { + return $multiValue[0]; + } + if (count($nested) === 1 && $consts === [] && $multiValue === []) { + return self::openFrom($nested[0], $title, $description, $nullable, $default, $extensions); + } + if ($consts !== [] && $nested === [] && $multiValue === []) { + return self::synthesize($consts, true, $title, $description, $nullable, $default, $extensions); + } + + return null; + } + + if ($consts !== [] && $nested === [] && $multiValue === []) { + return self::synthesize($consts, false, $title, $description, $nullable, $default, $extensions); + } + + return null; + } + + /** + * @param list $branches + * @param array $extensions + */ + private static function synthesize( + array $branches, + bool $open, + ?string $title, + string $description, + bool $nullable, + mixed $default, + array $extensions, + ): StringSchema { + $values = []; + $keys = []; + $allTitled = true; + foreach ($branches as $branch) { + $values[] = $branch['value']; + if ($branch['title'] === null || $branch['title'] === '') { + $allTitled = false; + + continue; + } + $keys[] = $branch['title']; + } + + return new StringSchema( + title: $title, + description: $description, + nullable: $nullable, + default: $default, + enum: $values, + extensions: $extensions, + enumName: $title, + enumKeys: $allTitled ? $keys : [], + open: $open, + ); + } + + /** @param array $extensions */ + private static function openFrom( + StringSchema $inner, + ?string $title, + string $description, + bool $nullable, + mixed $default, + array $extensions, + ): StringSchema { + return new StringSchema( + title: $title ?? $inner->title, + description: $description !== '' ? $description : $inner->description, + nullable: $nullable || $inner->nullable, + default: $default ?? $inner->default, + enum: $inner->enum, + extensions: $extensions !== [] ? $extensions : $inner->extensions, + enumName: $title ?? $inner->enumName, + enumKeys: $inner->enumKeys, + open: true, + ); + } + + private static function isUnconstrainedString(Schema $schema): bool + { + return $schema instanceof StringSchema + && $schema->enum === [] + && $schema->minLength === null + && $schema->maxLength === null + && $schema->pattern === null + && $schema->format === null; + } + + private static function isConstrainedString(StringSchema $schema): bool + { + return $schema->minLength !== null + || $schema->maxLength !== null + || $schema->pattern !== null + || $schema->format !== null; + } + + private static function stringConstValue(Schema $schema): ?string + { + if (count($schema->enum) !== 1 || ! is_string($schema->enum[0])) { + return null; + } + if ($schema instanceof StringSchema || $schema instanceof AnySchema) { + return $schema->enum[0]; + } + + return null; + } + + private static function isNonStringConst(Schema $schema): bool + { + return count($schema->enum) === 1 && ! is_string($schema->enum[0]); + } } diff --git a/src/Parser/Schema/Reader.php b/src/Parser/Schema/Reader.php index 09f4789..9143ee7 100644 --- a/src/Parser/Schema/Reader.php +++ b/src/Parser/Schema/Reader.php @@ -33,7 +33,6 @@ '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) {} @@ -76,7 +75,7 @@ public function read(mixed $raw, string $location): Schema $schemas[] = $this->read(['type' => $memberType], "{$location}/type/{$index}"); } - return new CompositeSchema(Composition::ANY_OF, $schemas, null, $this->discriminator($data), ...$common); + return new CompositeSchema(Composition::ANY_OF, $schemas, null, $this->discriminator($data), ...$common, location: $location); } $type = $types[0] ?? null; } @@ -92,11 +91,11 @@ public function read(mixed $raw, string $location): Schema } $not = array_key_exists('not', $data) ? $this->read($data['not'], "{$location}/not") : null; - return new CompositeSchema($composition, $schemas, $not, $this->discriminator($data), ...$common); + return new CompositeSchema($composition, $schemas, $not, $this->discriminator($data), ...$common, location: $location); } } if (array_key_exists('not', $data)) { - return new CompositeSchema(null, [], $this->read($data['not'], "{$location}/not"), $this->discriminator($data), ...$common); + return new CompositeSchema(null, [], $this->read($data['not'], "{$location}/not"), $this->discriminator($data), ...$common, location: $location); } if ($type === null) { @@ -117,8 +116,6 @@ 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: $this->enumKeys($data, $location, $common['enum']), ), 'integer' => $this->integer($data, $location, $common), 'number' => $this->number($data, $location, $common), @@ -150,25 +147,6 @@ 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 ($keys !== [] && 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 f812757..030c5b5 100644 --- a/tests/Schema/ReaderTest.php +++ b/tests/Schema/ReaderTest.php @@ -105,14 +105,215 @@ 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 + /** + * @return array + */ + private function annotatedWebhookEvent(): array + { + return [ + 'title' => 'WebhookEvent', + 'oneOf' => [ + ['const' => 'user.created', 'title' => 'UserCreated'], + ['const' => 'user.updated', 'title' => 'UserUpdated'], + ], + ]; + } + + public function test_closed_annotated_one_of_const_titles_become_a_string_enum(): void + { + $schema = $this->reader(Version::V3_1)->read($this->annotatedWebhookEvent(), '#/x'); + + self::assertInstanceOf(CompositeSchema::class, $schema); + $enum = $schema->stringEnum(); + self::assertInstanceOf(StringSchema::class, $enum); + self::assertSame(['user.created', 'user.updated'], $enum->enum); + self::assertSame(['UserCreated', 'UserUpdated'], $enum->enumKeys); + self::assertSame('WebhookEvent', $enum->enumName); + self::assertFalse($enum->open); + self::assertNull($schema->openStringEnumBranch()); + self::assertCount(2, $schema->schemas); + } + + public function test_closed_annotated_any_of_const_titles_become_a_string_enum(): void + { + $schema = $this->reader(Version::V3_1)->read([ + 'title' => 'WebhookEvent', + 'anyOf' => [ + ['const' => 'user.created', 'title' => 'UserCreated'], + ['const' => 'user.updated', 'title' => 'UserUpdated'], + ], + ], '#/x'); + + self::assertInstanceOf(CompositeSchema::class, $schema); + $enum = $schema->stringEnum(); + self::assertSame(['user.created', 'user.updated'], $enum?->enum); + self::assertSame(['UserCreated', 'UserUpdated'], $enum?->enumKeys); + self::assertSame('WebhookEvent', $enum?->enumName); + self::assertFalse($enum?->open); + self::assertNull($schema->openStringEnumBranch()); + } + + public function test_x_enum_extensions_remain_uninterpreted_on_a_string_schema(): void + { + $schema = $this->reader(Version::V3_0)->read([ + 'type' => 'string', + 'enum' => ['user.created', 'user.updated'], + 'x-enum-name' => 'WebhookEvent', + 'x-enum-keys' => ['UserCreated', 'UserUpdated'], + ], '#/x'); + + self::assertInstanceOf(StringSchema::class, $schema); + self::assertSame(['user.created', 'user.updated'], $schema->enum); + self::assertNull($schema->enumName); + self::assertSame([], $schema->enumKeys); + self::assertSame([ + 'x-enum-name' => 'WebhookEvent', + 'x-enum-keys' => ['UserCreated', 'UserUpdated'], + ], $schema->extensions); + } + + public function test_plain_string_enum_title_does_not_fill_enum_name_or_keys(): void + { + $schema = $this->reader(Version::V3_0)->read([ + 'title' => 'WebhookEvent', + 'type' => 'string', + 'enum' => ['user.created', 'user.updated'], + ], '#/x'); + + self::assertInstanceOf(StringSchema::class, $schema); + self::assertSame('WebhookEvent', $schema->title); + self::assertNull($schema->enumName); + self::assertSame([], $schema->enumKeys); + self::assertFalse($schema->open); + } + + public function test_missing_branch_titles_leave_enum_keys_empty(): void + { + $schema = $this->reader(Version::V3_1)->read([ + 'title' => 'WebhookEvent', + 'oneOf' => [ + ['const' => 'user.created', 'title' => 'UserCreated'], + ['const' => 'user.updated'], + ], + ], '#/x'); + + self::assertInstanceOf(CompositeSchema::class, $schema); + self::assertSame(['user.created', 'user.updated'], $schema->stringEnum()?->enum); + self::assertSame([], $schema->stringEnum()?->enumKeys); + self::assertSame('WebhookEvent', $schema->stringEnum()?->enumName); + } + + public function test_open_annotated_nested_one_of_preserves_keys(): void + { + $schema = $this->reader(Version::V3_1)->read([ + 'anyOf' => [ + $this->annotatedWebhookEvent(), + ['type' => 'string'], + ], + ], '#/x'); + + self::assertInstanceOf(CompositeSchema::class, $schema); + $enum = $schema->openStringEnumBranch(); + self::assertInstanceOf(StringSchema::class, $enum); + self::assertSame($enum, $schema->stringEnum()); + self::assertTrue($enum->open); + self::assertSame(['user.created', 'user.updated'], $enum->enum); + self::assertSame(['UserCreated', 'UserUpdated'], $enum->enumKeys); + self::assertSame('WebhookEvent', $enum->enumName); + self::assertInstanceOf(CompositeSchema::class, $schema->schemas[0]); + self::assertInstanceOf(StringSchema::class, $schema->schemas[1]); + self::assertFalse($schema->schemas[1]->open); + } + + public function test_open_flattened_consts_plus_unconstrained_string_preserve_keys(): void + { + $reader = $this->reader(Version::V3_1); + $consts = [ + ['const' => 'user.created', 'title' => 'UserCreated'], + ['const' => 'user.updated', 'title' => 'UserUpdated'], + ]; + $open = ['type' => 'string']; + + foreach ([[...$consts, $open], [$open, ...$consts]] as $branches) { + $schema = $reader->read(['title' => 'WebhookEvent', 'anyOf' => $branches], '#/x'); + + self::assertInstanceOf(CompositeSchema::class, $schema); + $enum = $schema->openStringEnumBranch(); + self::assertTrue($enum?->open); + self::assertSame(['user.created', 'user.updated'], $enum?->enum); + self::assertSame(['UserCreated', 'UserUpdated'], $enum?->enumKeys); + self::assertSame('WebhookEvent', $enum?->enumName); + } + } + + public function test_one_of_consts_only_is_not_open(): void + { + $schema = $this->reader(Version::V3_1)->read($this->annotatedWebhookEvent(), '#/x'); + + self::assertInstanceOf(CompositeSchema::class, $schema); + self::assertFalse($schema->stringEnum()?->open); + self::assertNull($schema->openStringEnumBranch()); + } + + public function test_object_const_mix_is_rejected(): void + { + $this->expectException(InvalidSpecification::class); + $this->expectExceptionMessage('#/components/schemas/Event'); + + $this->reader(Version::V3_1)->read([ + 'oneOf' => [ + ['const' => 'user.created', 'title' => 'UserCreated'], + ['type' => 'object', 'properties' => ['id' => ['type' => 'string']]], + ], + ], '#/components/schemas/Event'); + } + + public function test_numeric_const_mix_is_rejected(): void + { + $this->expectException(InvalidSpecification::class); + $this->expectExceptionMessage('#/x'); + + $this->reader(Version::V3_1)->read([ + 'oneOf' => [ + ['const' => 'user.created', 'title' => 'UserCreated'], + ['const' => 1, 'title' => 'One'], + ], + ], '#/x'); + } + + public function test_multi_value_enum_mixed_with_const_is_rejected(): void + { + $this->expectException(InvalidSpecification::class); + $this->expectExceptionMessage('#/x'); + + $this->reader(Version::V3_1)->read([ + 'anyOf' => [ + ['const' => 'user.created', 'title' => 'UserCreated'], + ['type' => 'string', 'enum' => ['a', 'b']], + ], + ], '#/x'); + } + + public function test_two_multi_value_enum_branches_are_not_an_annotated_enum(): void + { + $schema = $this->reader(Version::V3_0)->read([ + 'anyOf' => [ + ['type' => 'string', 'enum' => ['a', 'b']], + ['type' => 'string', 'enum' => ['c', 'd']], + ], + ], '#/x'); + + self::assertInstanceOf(CompositeSchema::class, $schema); + self::assertNull($schema->stringEnum()); + self::assertNull($schema->openStringEnumBranch()); + } + + public function test_legacy_open_multi_value_enum_still_sets_open(): void { $reader = $this->reader(Version::V3_0); $enum = [ 'type' => 'string', 'enum' => ['network.requests', 'network.inbound'], - 'x-enum-name' => 'UsageEventMetric', - 'x-enum-keys' => ['NetworkRequests', 'NetworkInbound'], ]; $open = ['type' => 'string']; @@ -122,56 +323,62 @@ public function test_open_string_enum_branch_is_exposed_regardless_of_branch_ord self::assertInstanceOf(CompositeSchema::class, $schema); $enumBranch = $schema->openStringEnumBranch(); self::assertSame(['network.requests', 'network.inbound'], $enumBranch?->enum); - self::assertSame('UsageEventMetric', $enumBranch?->enumName); - self::assertSame(['NetworkRequests', 'NetworkInbound'], $enumBranch?->enumKeys); + self::assertNull($enumBranch?->enumName); + self::assertSame([], $enumBranch?->enumKeys); self::assertTrue($enumBranch?->open); self::assertFalse($schema->schemas[$enumBranch === $schema->schemas[0] ? 1 : 0]->open); } } - public function test_enum_metadata_is_preserved_for_openapi_two_inline_parameters(): void + public function test_one_element_enums_plus_unconstrained_string_flatten_as_open_annotated(): void { - $schema = $this->reader(Version::V2)->readParameterFields([ - 'type' => 'string', - 'enum' => ['network.requests'], - 'x-enum-name' => 'UsageEventMetric', - 'x-enum-keys' => ['NetworkRequests'], - ], '#/parameters/metric'); + $schema = $this->reader(Version::V3_0)->read([ + 'anyOf' => [ + ['type' => 'string', 'enum' => ['first'], 'title' => 'First'], + ['type' => 'string', 'enum' => ['second'], 'title' => 'Second'], + ['type' => 'string'], + ], + ], '#/x'); - self::assertInstanceOf(StringSchema::class, $schema); - self::assertSame('UsageEventMetric', $schema->enumName); - self::assertSame(['NetworkRequests'], $schema->enumKeys); + self::assertInstanceOf(CompositeSchema::class, $schema); + $enum = $schema->openStringEnumBranch(); + self::assertTrue($enum?->open); + self::assertSame(['first', 'second'], $enum?->enum); + self::assertSame(['First', 'Second'], $enum?->enumKeys); } - public function test_empty_enum_keys_use_derived_names(): void + public function test_const_only_string_stays_a_closed_single_value_enum(): void { - $schema = $this->reader(Version::V3_0)->read([ - 'type' => 'string', - 'enum' => ['first', 'second'], - 'x-enum-keys' => [], - ], '#/x'); + $schema = $this->reader(Version::V3_1)->read(['type' => 'string', 'const' => 'pets'], '#/x'); self::assertInstanceOf(StringSchema::class, $schema); + self::assertSame(['pets'], $schema->enum); + self::assertFalse($schema->open); + self::assertNull($schema->enumName); self::assertSame([], $schema->enumKeys); } - public function test_enum_keys_must_match_enum_length(): void + public function test_annotated_const_branches_may_omit_type(): 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'], + $schema = $this->reader(Version::V3_1)->read([ + 'title' => 'WebhookEvent', + 'oneOf' => [ + ['const' => 'user.created', 'title' => 'UserCreated'], + ['const' => 'user.updated', 'title' => 'UserUpdated'], + ], ], '#/x'); + + self::assertInstanceOf(CompositeSchema::class, $schema); + self::assertInstanceOf(AnySchema::class, $schema->schemas[0]); + self::assertSame(['user.created', 'user.updated'], $schema->stringEnum()?->enum); + self::assertSame(['UserCreated', 'UserUpdated'], $schema->stringEnum()?->enumKeys); } public function test_open_string_enum_requires_any_of(): void { $reader = $this->reader(Version::V3_0); $branches = [ - ['type' => 'string', 'enum' => ['known']], + ['type' => 'string', 'enum' => ['a', 'b']], ['type' => 'string'], ]; @@ -187,22 +394,12 @@ public function test_open_string_enum_requires_one_enum_and_an_open_string_branc { $reader = $this->reader(Version::V3_0); $invalidUnions = [ - [['type' => 'string', 'enum' => ['known']]], + [['type' => 'string', 'enum' => ['a', 'b']]], [['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) { @@ -216,7 +413,7 @@ 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']]; + $enum = ['type' => 'string', 'enum' => ['a', 'b']]; $constraints = [ ['minLength' => 1], ['maxLength' => 10], @@ -239,6 +436,19 @@ public function test_open_string_enum_requires_an_unrestricted_string_branch(): } } + public function test_reference_mixed_with_const_is_not_an_annotated_enum(): void + { + $schema = $this->reader(Version::V3_1)->read([ + 'oneOf' => [ + ['const' => 'user.created', 'title' => 'UserCreated'], + ['$ref' => '#/components/schemas/Pet'], + ], + ], '#/x'); + + self::assertInstanceOf(CompositeSchema::class, $schema); + self::assertNull($schema->stringEnum()); + } + public function test_discriminator_is_read_from_both_the_string_and_object_forms(): void { $reader = $this->reader(Version::V3_0); diff --git a/tests/ValueTest.php b/tests/ValueTest.php index 4f6f25e..2891bd9 100644 --- a/tests/ValueTest.php +++ b/tests/ValueTest.php @@ -54,11 +54,11 @@ 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')); + self::assertSame(['first', 'second'], Value::stringList(['first', 'second'], '#/tags')); $this->expectException(InvalidSpecification::class); - $this->expectExceptionMessage('Expected string at #/x-enum-keys/1'); - Value::stringList(['first', 2], '#/x-enum-keys'); + $this->expectExceptionMessage('Expected string at #/tags/1'); + Value::stringList(['first', 2], '#/tags'); } public function test_required_string_names_the_missing_key(): void From 9428e5508bb3bbc61c5eb0531e7f8e616d935a2f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 21 Aug 2026 09:28:33 +0530 Subject: [PATCH 2/2] (docs): describe enum names using schema titles only --- CONTEXT.md | 2 +- README.md | 2 +- tests/Schema/ReaderTest.php | 19 ------------------- 3 files changed, 2 insertions(+), 21 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 837744a..b7cb494 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -30,4 +30,4 @@ Terms used across this library. Use these words in code, tests, and commits. **Annotated enumeration** — an OAS 3.1 `oneOf` or `anyOf` whose members are string `const` (or one-element `enum`) schemas, optionally with `title`/`description`. Mapped onto `StringSchema` (`enum`, `enumName` from the composite title, `enumKeys` from branch titles, `open` when composed with an unconstrained string). Exposed on `CompositeSchema` as `stringEnum()` without collapsing the union tree. -**Extension** — any `x-`-prefixed key. Captured on every model that can carry one; never interpreted. Enum type and value names come from `title`, not from `x-enum-name` / `x-enum-keys`. +**Extension** — any `x-`-prefixed key. Captured on every model that can carry one; never interpreted. Enum type and value names come from `title`. diff --git a/README.md b/README.md index af5ef0e..4207976 100644 --- a/README.md +++ b/README.md @@ -315,7 +315,7 @@ The core parser supports JSON strings and decoded PHP arrays. The following capa - Complete OpenAPI conformance validation - Serialization or textual round trips - Callbacks, links, and OpenAPI 3.1 webhooks -- Interpretation of vendor extensions (`x-enum-name` and `x-enum-keys` are retained as opaque extensions) +- Interpretation of vendor extensions - Swagger 1.x A missing `operationId` is currently accepted and represented as an empty string. diff --git a/tests/Schema/ReaderTest.php b/tests/Schema/ReaderTest.php index 030c5b5..eefc2ca 100644 --- a/tests/Schema/ReaderTest.php +++ b/tests/Schema/ReaderTest.php @@ -153,25 +153,6 @@ public function test_closed_annotated_any_of_const_titles_become_a_string_enum() self::assertNull($schema->openStringEnumBranch()); } - public function test_x_enum_extensions_remain_uninterpreted_on_a_string_schema(): void - { - $schema = $this->reader(Version::V3_0)->read([ - 'type' => 'string', - 'enum' => ['user.created', 'user.updated'], - 'x-enum-name' => 'WebhookEvent', - 'x-enum-keys' => ['UserCreated', 'UserUpdated'], - ], '#/x'); - - self::assertInstanceOf(StringSchema::class, $schema); - self::assertSame(['user.created', 'user.updated'], $schema->enum); - self::assertNull($schema->enumName); - self::assertSame([], $schema->enumKeys); - self::assertSame([ - 'x-enum-name' => 'WebhookEvent', - 'x-enum-keys' => ['UserCreated', 'UserUpdated'], - ], $schema->extensions); - } - public function test_plain_string_enum_title_does_not_fill_enum_name_or_keys(): void { $schema = $this->reader(Version::V3_0)->read([