diff --git a/src/Schema/Elicitation/AbstractSchemaDefinition.php b/src/Schema/Elicitation/AbstractSchemaDefinition.php index 63fdc219..a05135f1 100644 --- a/src/Schema/Elicitation/AbstractSchemaDefinition.php +++ b/src/Schema/Elicitation/AbstractSchemaDefinition.php @@ -21,13 +21,13 @@ abstract class AbstractSchemaDefinition implements \JsonSerializable { public function __construct( - public readonly string $title, + public readonly ?string $title = null, public readonly ?string $description = null, ) { } /** - * Validate that title exists and is a string in the data array. + * Reject a title that is present but not a string. * * @param array $data * @@ -35,22 +35,23 @@ public function __construct( */ protected static function validateTitle(array $data, string $schemaType): void { - if (!isset($data['title']) || !\is_string($data['title'])) { - throw new InvalidArgumentException(\sprintf('Missing or invalid "title" for %s schema definition.', $schemaType)); + if (\array_key_exists('title', $data) && null !== $data['title'] && !\is_string($data['title'])) { + throw new InvalidArgumentException(\sprintf('Invalid "title" for %s schema definition.', $schemaType)); } } /** - * Build the base JSON structure with type, title, and optional description. + * Build the base JSON structure with type, optional title and description. * * @return array */ protected function buildBaseJson(string $type): array { - $data = [ - 'type' => $type, - 'title' => $this->title, - ]; + $data = ['type' => $type]; + + if (null !== $this->title) { + $data['title'] = $this->title; + } if (null !== $this->description) { $data['description'] = $this->description; diff --git a/src/Schema/Elicitation/BooleanSchemaDefinition.php b/src/Schema/Elicitation/BooleanSchemaDefinition.php index 9cfaa8a3..39766858 100644 --- a/src/Schema/Elicitation/BooleanSchemaDefinition.php +++ b/src/Schema/Elicitation/BooleanSchemaDefinition.php @@ -19,12 +19,12 @@ final class BooleanSchemaDefinition extends AbstractSchemaDefinition { /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param string|null $description Optional description/help text * @param bool|null $default Optional default value */ public function __construct( - string $title, + ?string $title = null, ?string $description = null, public readonly ?bool $default = null, ) { @@ -33,7 +33,7 @@ public function __construct( /** * @param array{ - * title: string, + * title?: string, * description?: string, * default?: bool, * } $data @@ -43,7 +43,7 @@ public static function fromArray(array $data): self self::validateTitle($data, 'boolean'); return new self( - title: $data['title'], + title: $data['title'] ?? null, description: $data['description'] ?? null, default: isset($data['default']) ? (bool) $data['default'] : null, ); @@ -52,7 +52,7 @@ public static function fromArray(array $data): self /** * @return array{ * type: string, - * title: string, + * title?: string, * description?: string, * default?: bool, * } diff --git a/src/Schema/Elicitation/EnumSchemaDefinition.php b/src/Schema/Elicitation/EnumSchemaDefinition.php index 003bf354..1eaef8b5 100644 --- a/src/Schema/Elicitation/EnumSchemaDefinition.php +++ b/src/Schema/Elicitation/EnumSchemaDefinition.php @@ -23,14 +23,14 @@ final class EnumSchemaDefinition extends AbstractSchemaDefinition { /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param string[] $enum Array of allowed string values * @param string|null $description Optional description/help text * @param string|null $default Optional default value (must be in enum) * @param string[]|null $enumNames Optional human-readable labels for each enum value */ public function __construct( - string $title, + ?string $title, public readonly array $enum, ?string $description = null, public readonly ?string $default = null, @@ -59,7 +59,7 @@ public function __construct( /** * @param array{ - * title: string, + * title?: string, * enum: string[], * description?: string, * default?: string, @@ -75,7 +75,7 @@ public static function fromArray(array $data): self } return new self( - title: $data['title'], + title: $data['title'] ?? null, enum: $data['enum'], description: $data['description'] ?? null, default: $data['default'] ?? null, @@ -86,7 +86,7 @@ enumNames: $data['enumNames'] ?? null, /** * @return array{ * type: string, - * title: string, + * title?: string, * enum: string[], * description?: string, * default?: string, @@ -95,11 +95,13 @@ enumNames: $data['enumNames'] ?? null, */ public function jsonSerialize(): array { - $data = [ - 'type' => 'string', - 'title' => $this->title, - 'enum' => $this->enum, - ]; + $data = ['type' => 'string']; + + if (null !== $this->title) { + $data['title'] = $this->title; + } + + $data['enum'] = $this->enum; if (null !== $this->description) { $data['description'] = $this->description; diff --git a/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php b/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php index 28046bcf..ce2ab08e 100644 --- a/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php +++ b/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php @@ -23,7 +23,7 @@ final class MultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition { /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param string[] $enum Array of allowed string values * @param string|null $description Optional description/help text * @param string[]|null $default Optional default selected values (must be subset of enum) @@ -31,7 +31,7 @@ final class MultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition * @param int|null $maxItems Optional maximum number of selections */ public function __construct( - string $title, + ?string $title, public readonly array $enum, ?string $description = null, public readonly ?array $default = null, @@ -73,7 +73,7 @@ public function __construct( /** * @param array{ - * title: string, + * title?: string, * items: array{type: string, enum: string[]}, * description?: string, * default?: string[], @@ -90,7 +90,7 @@ public static function fromArray(array $data): self } return new self( - title: $data['title'], + title: $data['title'] ?? null, enum: $data['items']['enum'], description: $data['description'] ?? null, default: $data['default'] ?? null, diff --git a/src/Schema/Elicitation/NumberSchemaDefinition.php b/src/Schema/Elicitation/NumberSchemaDefinition.php index 0ad32771..d2bf540c 100644 --- a/src/Schema/Elicitation/NumberSchemaDefinition.php +++ b/src/Schema/Elicitation/NumberSchemaDefinition.php @@ -23,7 +23,7 @@ final class NumberSchemaDefinition extends AbstractSchemaDefinition { /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param bool $integerOnly Whether to restrict to integer values only * @param string|null $description Optional description/help text * @param int|float|null $default Optional default value @@ -31,7 +31,7 @@ final class NumberSchemaDefinition extends AbstractSchemaDefinition * @param int|float|null $maximum Optional maximum value (inclusive) */ public function __construct( - string $title, + ?string $title = null, public readonly bool $integerOnly = false, ?string $description = null, public readonly int|float|null $default = null, @@ -60,7 +60,7 @@ public function __construct( /** * @param array{ * type: string, - * title: string, + * title?: string, * description?: string, * default?: int|float, * minimum?: int|float, @@ -75,7 +75,7 @@ public static function fromArray(array $data): self $integerOnly = 'integer' === $type; return new self( - title: $data['title'], + title: $data['title'] ?? null, integerOnly: $integerOnly, description: $data['description'] ?? null, default: $data['default'] ?? null, @@ -87,7 +87,7 @@ public static function fromArray(array $data): self /** * @return array{ * type: string, - * title: string, + * title?: string, * description?: string, * default?: int|float, * minimum?: int|float, diff --git a/src/Schema/Elicitation/StringSchemaDefinition.php b/src/Schema/Elicitation/StringSchemaDefinition.php index 76319261..36c5e447 100644 --- a/src/Schema/Elicitation/StringSchemaDefinition.php +++ b/src/Schema/Elicitation/StringSchemaDefinition.php @@ -25,7 +25,7 @@ final class StringSchemaDefinition extends AbstractSchemaDefinition private const VALID_FORMATS = ['date', 'date-time', 'email', 'uri']; /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param string|null $description Optional description/help text * @param string|null $default Optional default value * @param string|null $format Optional format constraint (date, date-time, email, uri) @@ -33,7 +33,7 @@ final class StringSchemaDefinition extends AbstractSchemaDefinition * @param int|null $maxLength Optional maximum string length */ public function __construct( - string $title, + ?string $title = null, ?string $description = null, public readonly ?string $default = null, public readonly ?string $format = null, @@ -61,7 +61,7 @@ public function __construct( /** * @param array{ - * title: string, + * title?: string, * description?: string, * default?: string, * format?: string, @@ -74,7 +74,7 @@ public static function fromArray(array $data): self self::validateTitle($data, 'string'); return new self( - title: $data['title'], + title: $data['title'] ?? null, description: $data['description'] ?? null, default: $data['default'] ?? null, format: $data['format'] ?? null, @@ -86,7 +86,7 @@ public static function fromArray(array $data): self /** * @return array{ * type: string, - * title: string, + * title?: string, * description?: string, * default?: string, * format?: string, diff --git a/src/Schema/Elicitation/TitledEnumSchemaDefinition.php b/src/Schema/Elicitation/TitledEnumSchemaDefinition.php index 54eb568b..f6c47d4f 100644 --- a/src/Schema/Elicitation/TitledEnumSchemaDefinition.php +++ b/src/Schema/Elicitation/TitledEnumSchemaDefinition.php @@ -24,13 +24,13 @@ final class TitledEnumSchemaDefinition extends AbstractSchemaDefinition { /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param list $oneOf Array of const/title pairs * @param string|null $description Optional description/help text * @param string|null $default Optional default value (must match a const) */ public function __construct( - string $title, + ?string $title, public readonly array $oneOf, ?string $description = null, public readonly ?string $default = null, @@ -59,7 +59,7 @@ public function __construct( /** * @param array{ - * title: string, + * title?: string, * oneOf: list, * description?: string, * default?: string, @@ -74,7 +74,7 @@ public static function fromArray(array $data): self } return new self( - title: $data['title'], + title: $data['title'] ?? null, oneOf: $data['oneOf'], description: $data['description'] ?? null, default: $data['default'] ?? null, diff --git a/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php b/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php index baab9b0b..ee49a053 100644 --- a/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php +++ b/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php @@ -23,7 +23,7 @@ final class TitledMultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition { /** - * @param string $title Human-readable title for the field + * @param ?string $title Optional human-readable title for the field * @param list $anyOf Array of const/title pairs * @param string|null $description Optional description/help text * @param string[]|null $default Optional default selected values (must be subset of anyOf consts) @@ -31,7 +31,7 @@ final class TitledMultiSelectEnumSchemaDefinition extends AbstractSchemaDefiniti * @param int|null $maxItems Optional maximum number of selections */ public function __construct( - string $title, + ?string $title, public readonly array $anyOf, ?string $description = null, public readonly ?array $default = null, @@ -78,7 +78,7 @@ public function __construct( /** * @param array{ - * title: string, + * title?: string, * items: array{anyOf: list}, * description?: string, * default?: string[], @@ -95,7 +95,7 @@ public static function fromArray(array $data): self } return new self( - title: $data['title'], + title: $data['title'] ?? null, anyOf: $data['items']['anyOf'], description: $data['description'] ?? null, default: $data['default'] ?? null, diff --git a/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php index fefea478..a9e11732 100644 --- a/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php @@ -71,13 +71,23 @@ public function testFromArrayWithAllParams(): void $this->assertTrue($schema->default); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = BooleanSchemaDefinition::fromArray([]); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for boolean schema definition.'); /* @phpstan-ignore argument.type */ - BooleanSchemaDefinition::fromArray([]); + BooleanSchemaDefinition::fromArray(['title' => 42]); } public function testJsonSerializeWithMinimalParams(): void diff --git a/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php index 1bd55ccd..ea200a86 100644 --- a/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php @@ -114,13 +114,23 @@ public function testFromArrayWithAllParams(): void $this->assertSame(['Poor', 'Fair', 'Good'], $schema->enumNames); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = EnumSchemaDefinition::fromArray(['enum' => ['a', 'b']]); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for enum schema definition.'); /* @phpstan-ignore argument.type */ - EnumSchemaDefinition::fromArray(['enum' => ['a', 'b']]); + EnumSchemaDefinition::fromArray(['title' => 42]); } public function testFromArrayWithMissingEnum(): void diff --git a/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php index 167ad08e..208f5d2c 100644 --- a/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php @@ -137,13 +137,26 @@ public function testFromArrayWithAllParams(): void $this->assertSame(3, $schema->maxItems); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = MultiSelectEnumSchemaDefinition::fromArray([ + 'items' => ['type' => 'string', 'enum' => ['a']], + ]); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for multi-select enum schema definition.'); /* @phpstan-ignore argument.type */ MultiSelectEnumSchemaDefinition::fromArray([ + 'title' => 42, 'items' => ['type' => 'string', 'enum' => ['a']], ]); } diff --git a/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php index 8ebdee74..ea040382 100644 --- a/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php @@ -133,13 +133,23 @@ public function testFromArrayWithAllParams(): void $this->assertSame(10, $schema->maximum); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = NumberSchemaDefinition::fromArray(['type' => 'integer']); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for number schema definition.'); /* @phpstan-ignore argument.type */ - NumberSchemaDefinition::fromArray(['type' => 'integer']); + NumberSchemaDefinition::fromArray(['title' => 42]); } public function testJsonSerializeAsInteger(): void diff --git a/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php index dbb3277d..02998cce 100644 --- a/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php @@ -114,13 +114,23 @@ public function testFromArrayWithAllParams(): void $this->assertSame(100, $schema->maxLength); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = StringSchemaDefinition::fromArray([]); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for string schema definition.'); /* @phpstan-ignore argument.type */ - StringSchemaDefinition::fromArray([]); + StringSchemaDefinition::fromArray(['title' => 42]); } public function testJsonSerializeWithMinimalParams(): void diff --git a/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php index f8c706f0..26502832 100644 --- a/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php @@ -124,13 +124,23 @@ public function testFromArrayWithAllParams(): void $this->assertSame('b', $schema->default); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = TitledEnumSchemaDefinition::fromArray(['oneOf' => [['const' => 'a', 'title' => 'A']]]); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for titled enum schema definition.'); /* @phpstan-ignore argument.type */ - TitledEnumSchemaDefinition::fromArray(['oneOf' => [['const' => 'a', 'title' => 'A']]]); + TitledEnumSchemaDefinition::fromArray(['title' => 42]); } public function testFromArrayWithMissingOneOf(): void diff --git a/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php index 6c1b776e..170893fb 100644 --- a/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php @@ -166,13 +166,26 @@ public function testFromArrayWithAllParams(): void $this->assertSame(2, $schema->maxItems); } - public function testFromArrayWithMissingTitle(): void + public function testFromArrayWithoutTitleIsAccepted(): void + { + // `title` is optional in the specification, so a server that omits it + // must still be readable. + $schema = TitledMultiSelectEnumSchemaDefinition::fromArray([ + 'items' => ['anyOf' => [['const' => 'a', 'title' => 'A']]], + ]); + + $this->assertNull($schema->title); + $this->assertArrayNotHasKey('title', $schema->jsonSerialize()); + } + + public function testFromArrayRejectsNonStringTitle(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing or invalid "title"'); + $this->expectExceptionMessage('Invalid "title" for titled multi-select enum schema definition.'); /* @phpstan-ignore argument.type */ TitledMultiSelectEnumSchemaDefinition::fromArray([ + 'title' => 42, 'items' => ['anyOf' => [['const' => 'a', 'title' => 'A']]], ]); }