diff --git a/CHANGELOG.md b/CHANGELOG.md index cd3d1380..0b9a013a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] `DocBlockParser::parseDocBlock()` no longer accepts `null`; pass `ReflectionMethod::getDocComment()` as is * [BC Break] Fix a `DELETE` of an unknown or ended session being answered `200` instead of the `404` the spec requires: `Protocol::destroySession()` and `BaseTransport::handleSessionEnd()` return `false` for such a session, as does the listener of `TransportInterface::onSessionEnd()`. A listener that returns nothing keeps the `200`. * Fix tools with an array shape parameter whose keys are nullable or unions (e.g. `array{name: string, nickname: string|null}`) rejecting valid arguments with `-32602`: a `null` inside the shape no longer turns the parameter into `array|null`, and such keys get their own types instead of `object`, including lists and nested shapes (`string[]|null`, `array{x: int}|null`). +* Fix tools whose DocBlock uses a PHPStan/Psalm pseudo-type or a literal type (`non-empty-string`, `positive-int`, `'asc'|'desc'`, `array`, ...) rejecting valid arguments with `-32602`: parameters and array shape keys now get the JSON Schema type of their base type instead of `object`. * Fix `array` and other generics with a union element getting `items` of type `object`. A `null` inside a generic no longer makes the parameter nullable, and neither does a DocBlock class whose name contains `null`. * Fix `InMemorySessionStore::exists()` reporting a session past its TTL as existing, while `read()` already treated it as gone. * Fix `Psr16NotificationBus` subscribers missing the notifications published after the bus stayed quiet for longer than its TTL: the sequence counter no longer expires, and a subscriber whose cursor is ahead of a lost counter reads what the bus holds. diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index c47e4497..c079aaad 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -708,17 +708,39 @@ private function getTypeStringFromReflection(?\ReflectionType $type, bool $nativ */ public static function splitUnion(string $type): array { - $branches = []; + return self::splitTopLevel($type, '|'); + } + + /** + * Splits a type string on a separator outside of generics, shapes, parentheses and quoted literals. + * + * @return list + */ + private static function splitTopLevel(string $type, string $separator): array + { + $parts = []; $depth = 0; $current = ''; + $quote = null; + $escaped = false; foreach (str_split($type) as $character) { - if (\in_array($character, ['<', '{', '('], true)) { + if (null !== $quote) { + if ($escaped) { + $escaped = false; + } elseif ('\\' === $character) { + $escaped = true; + } elseif ($quote === $character) { + $quote = null; + } + } elseif ('\'' === $character || '"' === $character) { + $quote = $character; + } elseif (\in_array($character, ['<', '{', '('], true)) { ++$depth; } elseif (\in_array($character, ['>', '}', ')'], true)) { $depth = max(0, $depth - 1); - } elseif ('|' === $character && 0 === $depth) { - $branches[] = trim($current); + } elseif ($separator === $character && 0 === $depth) { + $parts[] = trim($current); $current = ''; continue; @@ -727,9 +749,9 @@ public static function splitUnion(string $type): array $current .= $character; } - $branches[] = trim($current); + $parts[] = trim($current); - return array_values(array_filter($branches, static fn (string $branch): bool => '' !== $branch)); + return array_values(array_filter($parts, static fn (string $part): bool => '' !== $part)); } /** @@ -752,6 +774,10 @@ private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array { $normalizedType = strtolower(trim($phpTypeString)); + if (str_starts_with($normalizedType, '?')) { + $normalizedType = 'null|'.substr($normalizedType, 1); + } + // PRIORITY 1: Handle unions before anything else. A `|` at the top // level joins alternatives; one inside `<>` or `{}` belongs to a // generic (`array`) and is not a union of the whole type. @@ -773,33 +799,34 @@ private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array return ['object']; } - // PRIORITY 3: Check for array syntax (T[] or generics) + // PRIORITY 3: Treat PHPStan/Psalm integer ranges and other pseudo-types + // (`non-empty-string`, `positive-int`, literals, ...) as their base type + if (null !== $pseudoTypes = self::mapPseudoTypeToJsonSchemaTypes($normalizedType)) { + return $pseudoTypes; + } + + // A non-empty map with string keys is a JSON object; it cannot be the `[]` that an empty `{}` decodes to. + if (preg_match('/^non-empty-array\s*mapTypeToSchema($normalizedType)['type']; + } + + // PRIORITY 4: Check for array syntax (T[] or generics) if ( str_contains($normalizedType, '[]') - || preg_match('/^(array|list|iterable|collection)]+\s*>$/i', $normalizedType)) { - return ['integer']; - } - // PRIORITY 5: Handle simple built-in types return match ($normalizedType) { 'string', 'scalar' => ['string'], - '?string' => ['null', 'string'], 'int', 'integer' => ['integer'], - '?int', '?integer' => ['null', 'integer'], 'float', 'double', 'number' => ['number'], - '?float', '?double', '?number' => ['null', 'number'], 'bool', 'boolean' => ['boolean'], - '?bool', '?boolean' => ['null', 'boolean'], 'array' => ['array'], - '?array' => ['null', 'array'], 'object', 'stdclass' => ['object'], - '?object', '?stdclass' => ['null', 'object'], 'null' => ['null'], 'resource', 'callable' => ['object'], 'mixed' => [], @@ -808,6 +835,49 @@ private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array }; } + /** + * Maps a PHPStan/Psalm pseudo-type or a literal type to the JSON Schema + * types of its base type, e.g. `non-empty-string` to `string`, + * `positive-int` or `int<0, max>` to `integer` and `'a'` to `string`. + * + * @return list|null null when the type is not a known pseudo-type + */ + private static function mapPseudoTypeToJsonSchemaTypes(string $type): ?array + { + $type = strtolower(trim($type)); + + if (preg_match('/^int\s*<\s*[^<>]+\s*>$/', $type)) { + return ['integer']; + } + + if (preg_match('/^class-string\s*<[^<>]+>$/', $type)) { + return ['string']; + } + + // Literal types: 'foo', "foo", 42, -1.5 + if (preg_match('/^(\'(?:[^\'\\\\]|\\\\.)*\'|"(?:[^"\\\\]|\\\\.)*")$/s', $type)) { + return ['string']; + } + if (preg_match('/^-?\d+$/', $type)) { + return ['integer']; + } + if (preg_match('/^-?\d*\.\d+$/', $type)) { + return ['number']; + } + + return match ($type) { + 'non-empty-string', 'non-falsy-string', 'truthy-string', 'numeric-string', 'literal-string', + 'non-empty-literal-string', 'lowercase-string', 'non-empty-lowercase-string', 'uppercase-string', + 'non-empty-uppercase-string', 'class-string', 'interface-string', 'trait-string', 'enum-string', + 'callable-string' => ['string'], + 'positive-int', 'negative-int', 'non-positive-int', 'non-negative-int', 'non-zero-int' => ['integer'], + 'true', 'false' => ['boolean'], + 'array-key' => ['integer', 'string'], + 'numeric' => ['integer', 'number', 'string'], + default => null, + }; + } + /** * Infers the 'items' schema type for an array based on DocBlock type hints. * @@ -830,8 +900,11 @@ private function inferArrayItemsType(string $phpTypeString): string|array } // Case 2: Generic array syntax (e.g., array, array, etc.) - if (preg_match('/^(\\??)array\s*<\s*([\w\\\\|?]+)\s*>$/i', $normalizedType, $matches)) { + if (preg_match('/^(\\??)(?:array|list|non-empty-array|non-empty-list)\s*<\s*((?:[\w\\\\|?-]|\'[^\']*\'|"[^"]*")+)\s*>$/i', $normalizedType, $matches)) { $itemSchema = $this->mapTypeToSchema(strtolower($matches[2])); + if (!isset($itemSchema['type'])) { + return 'any'; + } return \is_string($itemSchema['type']) && 1 === \count($itemSchema) ? $itemSchema['type'] : $itemSchema; } @@ -844,7 +917,7 @@ private function inferArrayItemsType(string $phpTypeString): string|array // Return a schema for array with items being arrays return [ 'type' => 'array', - 'items' => $this->mapTypeToSchema(strtolower($matches[2])), + 'items' => $this->mapTypeToSchema(strtolower($matches[2])) ?: new \stdClass(), ]; } @@ -870,34 +943,8 @@ private function parseObjectLikeArray(string $propertiesStr): array $properties = []; $required = []; - // Parse properties from the string, handling nested structures - $depth = 0; - $buffer = ''; - - for ($i = 0; $i < \strlen($propertiesStr); ++$i) { - $char = $propertiesStr[$i]; - - // Track nested braces - if ('{' === $char) { - ++$depth; - $buffer .= $char; - } elseif ('}' === $char) { - --$depth; - $buffer .= $char; - } - // Property separator (comma) - elseif (',' === $char && 0 === $depth) { - // Process the completed property - $this->parsePropertyDefinition(trim($buffer), $properties, $required); - $buffer = ''; - } else { - $buffer .= $char; - } - } - - // Process the last property - if (!empty($buffer)) { - $this->parsePropertyDefinition(trim($buffer), $properties, $required); + foreach (self::splitTopLevel($propertiesStr, ',') as $propDefinition) { + $this->parsePropertyDefinition($propDefinition, $properties, $required); } if (!empty($properties)) { @@ -924,7 +971,8 @@ private function parsePropertyDefinition(string $propDefinition, array &$propert // Add to required properties $required[] = $propName; - $properties[$propName] = $this->mapTypeToSchema($propType); + // `mixed` accepts anything, which an empty schema expresses; it must encode as `{}`, not `[]`. + $properties[$propName] = $this->mapTypeToSchema($propType) ?: new \stdClass(); } } @@ -943,6 +991,12 @@ private function mapTypeToSchema(string $type): array if (\count($branches) > 1) { $nonNull = array_values(array_filter($branches, static fn (string $branch): bool => 'null' !== strtolower($branch))); $schemas = array_map($this->mapTypeToSchema(...), $nonNull); + // A branch without a type (`mixed`) accepts anything, and so does the union. + foreach ($schemas as $branchSchema) { + if (!isset($branchSchema['type'])) { + return []; + } + } $types = array_values(array_unique(array_merge(...array_map(static fn (array $schema): array => (array) $schema['type'], $schemas)))); if (\count($nonNull) < \count($branches)) { $types[] = 'null'; @@ -959,11 +1013,36 @@ private function mapTypeToSchema(string $type): array return $this->parseObjectLikeArray($matches[1]); } + // `array`: only string keys rule out a JSON array; sparse int keys arrive as a JSON object + if (preg_match('/^(?:non-empty-)?array\s*<\s*((?:\'[^\']*\'|"[^"]*"|[^<>{},\'"]|<[^<>]*>)+?)\s*,\s*(.+?)\s*>$/is', $type, $matches)) { + $keyTypes = $this->mapPhpTypeToJsonSchemaType($matches[1]); + $valueSchema = $this->mapTypeToSchema($matches[2]); + $items = ['items' => $valueSchema ?: new \stdClass()]; + $additionalProperties = ['additionalProperties' => $valueSchema ?: true]; + + return ['string'] === $keyTypes + ? ['type' => 'object'] + $additionalProperties + : ['type' => ['array', 'object']] + $items + $additionalProperties; + } + + // `array`, `list`, `non-empty-list`, `non-empty-array` and `T[]` if ( - preg_match('/^array\s*<\s*([\w\\\\|?]+)\s*>$/i', $type, $matches) + preg_match('/^(?:array|list|non-empty-array|non-empty-list)\s*<\s*(.+?)\s*>$/is', $type, $matches) || preg_match('/^([\w\\\\]+)\s*\[\]$/i', $type, $matches) ) { - return ['type' => 'array', 'items' => $this->mapTypeToSchema($matches[1])]; + return ['type' => 'array', 'items' => $this->mapTypeToSchema($matches[1]) ?: new \stdClass()]; + } + + if (\in_array(strtolower($type), ['list', 'non-empty-array', 'non-empty-list'], true)) { + return ['type' => 'array']; + } + + if ('mixed' === strtolower($type)) { + return []; + } + + if (null !== $pseudoTypes = self::mapPseudoTypeToJsonSchemaTypes($type)) { + return ['type' => 1 === \count($pseudoTypes) ? $pseudoTypes[0] : $pseudoTypes]; } return ['type' => 'null' === strtolower($type) ? 'null' : $this->mapSimpleTypeToJsonSchema($type)]; diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 971d6870..2d529e5a 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -335,6 +335,112 @@ public function arrayShapesWithUnionMembers( ): void { } + /** + * PHPStan/Psalm pseudo-types and literal types map to their base type. + * + * @param non-empty-string $name Name + * @param class-string $class Class + * @param positive-int $count Count + * @param array-key $key Key + * @param 'asc'|'desc' $order Order + * @param 1|2 $level Level + * @param non-empty-list $tags Tags + * @param array{tags: list, ids: array, meta: array, any: mixed} $settings Settings + */ + public function pseudoTypes( + string $name, + string $class, + int $count, + int|string $key, + string $order, + int $level, + array $tags, + array $settings, + ): void { + } + + /** + * The key type of a map inside an array shape decides between a JSON array and a JSON object. + * + * @param array{map: array} $stringKey + * @param array{map: array} $pseudoStringKey + * @param array{map: array, int>} $genericStringKey + * @param array{map: array} $intKey + * @param array{map: array, int>} $rangeKey + * @param array{map: array} $arrayKey + * @param array{map: array} $unionKey + * @param array{map: array} $arrayKeyMixedValue + * @param array{map: array|null} $nullableArrayKey + * @param array{map: array} $shapeValue + */ + public function arrayShapeMapKeys( + array $stringKey, + array $pseudoStringKey, + array $genericStringKey, + array $intKey, + array $rangeKey, + array $arrayKey, + array $unionKey, + array $arrayKeyMixedValue, + array $nullableArrayKey, + array $shapeValue, + ): void { + } + + /** + * @param ?non-empty-string $nullableString + * @param ?positive-int $nullableInt + * @param ?array-key $nullableArrayKey + * @param ?list $nullableList + * @param non-empty-array $stringKeyMap + * @param non-empty-array|null $nullableStringKeyMap + * @param non-empty-array $intKeyMap + * @param non-empty-array $arrayKeyMap + * @param non-empty-array $singleArgument + */ + public function nullableAndNonEmptyPseudoTypes( + ?string $nullableString, + ?int $nullableInt, + int|string|null $nullableArrayKey, + ?array $nullableList, + array $stringKeyMap, + ?array $nullableStringKeyMap, + array $intKeyMap, + array $arrayKeyMap, + array $singleArgument, + ): void { + } + + /** + * @param 'a|b'|'c' $pipeLiteral + * @param "a|b" $doubleQuotedPipeLiteral + * @param 'a[]' $bracketLiteral + * @param 'it\'s' $escapedLiteral + * @param array{operator: '<', limit: int} $lessThanLiteral + * @param array{operator: '>', limit: int} $greaterThanLiteral + * @param array{label: 'a,b', limit: int} $commaLiteral + * @param array{label: '{', limit: int} $braceLiteral + * @param list<'a,b'> $commaLiteralList + * @param array> $nestedMixed + * @param array> $nestedMixedUnion + * @param mixed[][] $nestedMixedBrackets + */ + public function quotedLiteralsAndNestedMixed( + string $pipeLiteral, + string $doubleQuotedPipeLiteral, + string $bracketLiteral, + string $escapedLiteral, + array $lessThanLiteral, + array $greaterThanLiteral, + array $commaLiteral, + array $braceLiteral, + array $commaLiteralList, + array $nestedMixed, + array $nestedMixedUnion, + array $nestedMixedBrackets, + ): void { + } + // ===== NULLABLE AND OPTIONAL SCENARIOS ===== /** diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 1e046a13..28413096 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -13,6 +13,7 @@ use Mcp\Capability\Discovery\DocBlockParser; use Mcp\Capability\Discovery\SchemaGenerator; +use Mcp\Capability\Discovery\SchemaValidator; use Mcp\Exception\InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -252,6 +253,176 @@ public function testMapsPhpStanAndPsalmIntegerRangesToBaseIntegerType(): void $this->assertEquals(['type' => 'integer', 'description' => 'Bounded value'], $schema['properties']['bounded']); } + public function testMapsPhpStanAndPsalmPseudoTypesToTheirBaseType(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'pseudoTypes'); + $schema = $this->schemaGenerator->generate($method); + + $this->assertEquals(['type' => 'string', 'description' => 'Name'], $schema['properties']['name']); + $this->assertEquals(['type' => 'string', 'description' => 'Class'], $schema['properties']['class']); + $this->assertEquals(['type' => 'integer', 'description' => 'Count'], $schema['properties']['count']); + $this->assertEquals(['type' => ['integer', 'string'], 'description' => 'Key'], $schema['properties']['key']); + $this->assertEquals(['type' => 'string', 'description' => 'Order'], $schema['properties']['order']); + $this->assertEquals(['type' => 'integer', 'description' => 'Level'], $schema['properties']['level']); + $this->assertEquals(['type' => 'array', 'description' => 'Tags', 'items' => ['type' => 'string']], $schema['properties']['tags']); + + $settings = $schema['properties']['settings']; + $this->assertSame('object', $settings['type']); + $this->assertEquals(['type' => 'array', 'items' => ['type' => 'string']], $settings['properties']['tags']); + $this->assertEquals(['type' => ['array', 'object'], 'items' => ['type' => 'integer'], 'additionalProperties' => ['type' => 'integer']], $settings['properties']['ids']); + $this->assertEquals(['type' => 'object', 'additionalProperties' => ['type' => 'integer']], $settings['properties']['meta']); + // `mixed` accepts any value, serialized as the empty schema `{}` + $this->assertSame('{}', json_encode($settings['properties']['any'])); + $this->assertEqualsCanonicalizing(['tags', 'ids', 'meta', 'any'], $settings['required']); + } + + /** + * @param array $expected + * @param list $valid JSON values of the `map` key that must pass validation + * @param list $invalid JSON values of the `map` key that must fail validation + */ + #[DataProvider('provideArrayShapeMapKeys')] + public function testMapsTheKeyTypeOfAMapInsideAnArrayShape(string $parameter, array $expected, array $valid, array $invalid): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'arrayShapeMapKeys'); + $schema = $this->schemaGenerator->generate($method)['properties'][$parameter]; + + $this->assertEquals($expected, $schema['properties']['map']); + + $validator = new SchemaValidator(); + foreach ($valid as $map) { + $this->assertSame([], $validator->validateAgainstJsonSchema(['map' => json_decode($map, true)], $schema), $map); + } + foreach ($invalid as $map) { + $this->assertNotSame([], $validator->validateAgainstJsonSchema(['map' => json_decode($map, true)], $schema), $map); + } + } + + /** + * @return iterable, list, list}> + */ + public static function provideArrayShapeMapKeys(): iterable + { + $object = ['type' => 'object', 'additionalProperties' => ['type' => 'integer']]; + $either = ['type' => ['array', 'object'], 'items' => ['type' => 'integer'], 'additionalProperties' => ['type' => 'integer']]; + + yield 'string' => ['stringKey', $object, ['{"x":1}'], ['{"x":"y"}', '"x"']]; + yield 'string pseudo-type' => ['pseudoStringKey', $object, ['{"x":1}'], ['{"x":"y"}', '"x"']]; + yield 'generic string pseudo-type' => ['genericStringKey', $object, ['{"stdClass":1}'], ['{"stdClass":"y"}', '"x"']]; + yield 'int' => ['intKey', $either, ['[1]', '[]', '{"5":1}'], ['["y"]', '{"5":"y"}', '"x"']]; + yield 'int range' => ['rangeKey', $either, ['[1]', '[]', '{"5":1}'], ['["y"]', '{"5":"y"}', '"x"']]; + yield 'array-key' => ['arrayKey', $either, ['{"x":1}', '[1]', '{}', '{"0":1}'], ['{"x":"y"}', '["y"]', '"x"', 'null']]; + yield 'int|string' => ['unionKey', $either, ['{"x":1}', '[1]', '{}', '{"0":1}'], ['{"x":"y"}', '["y"]', '"x"', 'null']]; + yield 'array-key with mixed value' => [ + 'arrayKeyMixedValue', + ['type' => ['array', 'object'], 'items' => new \stdClass(), 'additionalProperties' => true], + ['{"x":1}', '{"x":"y"}', '[1,"y"]', '{}'], + ['"x"', 'null'], + ]; + yield 'nullable array-key' => [ + 'nullableArrayKey', + ['type' => ['array', 'object', 'null']] + $either, + ['{"x":1}', '[1]', 'null'], + ['{"x":"y"}', '["y"]', '"x"'], + ]; + yield 'single argument with a shape value' => [ + 'shapeValue', + ['type' => 'array', 'items' => [ + 'type' => 'object', + 'properties' => ['a' => ['type' => 'integer'], 'b' => ['type' => 'integer']], + 'required' => ['a', 'b'], + ]], + ['[{"a":1,"b":2}]', '[]'], + ['[{"a":1}]', '{"x":{"a":1,"b":2}}'], + ]; + } + + /** + * @param array $expected + * @param list $valid JSON arguments that must pass validation + * @param list $invalid JSON arguments that must fail validation + */ + #[DataProvider('provideNullableAndNonEmptyPseudoTypes')] + public function testMapsNullableAndNonEmptyPseudoTypes(string $parameter, array $expected, array $valid, array $invalid): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'nullableAndNonEmptyPseudoTypes'); + $schema = $this->schemaGenerator->generate($method)['properties'][$parameter]; + + $this->assertEquals($expected, $schema); + + $validator = new SchemaValidator(); + foreach ($valid as $argument) { + $this->assertSame([], $validator->validateAgainstJsonSchema(json_decode($argument, true), $schema), $argument); + } + foreach ($invalid as $argument) { + $this->assertNotSame([], $validator->validateAgainstJsonSchema(json_decode($argument, true), $schema), $argument); + } + } + + /** + * @return iterable, list, list}> + */ + public static function provideNullableAndNonEmptyPseudoTypes(): iterable + { + yield '?non-empty-string' => ['nullableString', ['type' => ['null', 'string']], ['"a"', 'null'], ['1']]; + yield '?positive-int' => ['nullableInt', ['type' => ['null', 'integer']], ['1', 'null'], ['"a"']]; + yield '?array-key' => ['nullableArrayKey', ['type' => ['null', 'integer', 'string']], ['1', '"a"', 'null'], ['true']]; + yield '?list' => ['nullableList', ['type' => ['array', 'null'], 'items' => ['type' => 'string']], ['["a"]', 'null'], ['[1]', '"a"']]; + yield 'non-empty-array' => ['stringKeyMap', ['type' => 'object'], ['{"a":1}'], ['["a"]', '"a"']]; + yield 'non-empty-array|null' => ['nullableStringKeyMap', ['type' => ['null', 'object']], ['{"a":1}', 'null'], ['["a"]']]; + yield 'non-empty-array' => ['intKeyMap', ['type' => ['array', 'object'], 'items' => new \stdClass()], ['[1]', '{"5":1}'], ['"a"']]; + yield 'non-empty-array' => ['arrayKeyMap', ['type' => ['array', 'object'], 'items' => new \stdClass()], ['{"a":1}', '[1]'], ['"a"']]; + yield 'non-empty-array' => ['singleArgument', ['type' => 'array', 'items' => ['type' => 'integer']], ['[1]'], ['["a"]', '{"a":1}']]; + } + + /** + * @param array $expected + * @param list $valid JSON arguments that must pass validation + * @param list $invalid JSON arguments that must fail validation + */ + #[DataProvider('provideQuotedLiteralsAndNestedMixed')] + public function testMapsQuotedLiteralsAndNestedMixed(string $parameter, array $expected, array $valid, array $invalid): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'quotedLiteralsAndNestedMixed'); + $schema = $this->schemaGenerator->generate($method)['properties'][$parameter]; + + $this->assertEquals($expected, $schema); + + $validator = new SchemaValidator(); + foreach ($valid as $argument) { + $this->assertSame([], $validator->validateAgainstJsonSchema(json_decode($argument, true), $schema), $argument); + } + foreach ($invalid as $argument) { + $this->assertNotSame([], $validator->validateAgainstJsonSchema(json_decode($argument, true), $schema), $argument); + } + } + + /** + * @return iterable, list, list}> + */ + public static function provideQuotedLiteralsAndNestedMixed(): iterable + { + $shape = static fn (string $key): array => [ + 'type' => 'object', + 'properties' => [$key => ['type' => 'string'], 'limit' => ['type' => 'integer']], + 'required' => [$key, 'limit'], + ]; + $nested = ['type' => 'array', 'items' => ['type' => 'array', 'items' => new \stdClass()]]; + + yield 'pipe in a literal' => ['pipeLiteral', ['type' => 'string'], ['"a|b"', '"c"'], ['1']]; + yield 'pipe in a double quoted literal' => ['doubleQuotedPipeLiteral', ['type' => 'string'], ['"a|b"'], ['1']]; + yield 'brackets in a literal' => ['bracketLiteral', ['type' => 'string'], ['"a[]"'], ['["a"]']]; + yield 'escaped quote in a literal' => ['escapedLiteral', ['type' => 'string'], ['"it\'s"'], ['1']]; + yield 'less than in a shape literal' => ['lessThanLiteral', $shape('operator'), ['{"operator":"<","limit":1}'], ['{"operator":"<"}', '{"operator":1,"limit":1}']]; + yield 'greater than in a shape literal' => ['greaterThanLiteral', $shape('operator'), ['{"operator":">","limit":1}'], ['{"operator":">"}']]; + yield 'comma in a shape literal' => ['commaLiteral', $shape('label'), ['{"label":"a,b","limit":1}'], ['{"label":"a,b"}']]; + yield 'brace in a shape literal' => ['braceLiteral', $shape('label'), ['{"label":"{","limit":1}'], ['{"label":"{"}']]; + yield 'comma in a list literal' => ['commaLiteralList', ['type' => 'array', 'items' => ['type' => 'string']], ['["a,b"]'], ['[1]']]; + yield 'nested mixed' => ['nestedMixed', $nested, ['[[1,"x"]]', '[]'], ['[1]', '"x"']]; + yield 'nested mixed union' => ['nestedMixedUnion', $nested, ['[[1,"x"]]'], ['[1]']]; + yield 'nested mixed brackets' => ['nestedMixedBrackets', $nested, ['[[1,"x"]]'], ['[1]']]; + } + public function testGeneratesCorrectSchemaForEnumParameters(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'enumParameters'); diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorUnionTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorUnionTest.php index 9a8a6cf0..eb5672bd 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorUnionTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorUnionTest.php @@ -67,6 +67,10 @@ public static function mappedTypes(): iterable yield 'a generic union is still one array' => ['array', ['array']]; yield 'a shape is still one object' => ['array{a: int|string}', ['object']]; + + yield 'a literal stays a string' => ["'a[]'", ['string']]; + yield 'an array of literals stays an array' => ["'asc'[]", ['array']]; + yield 'an array of bracket literals stays an array' => ['"a[]"[]', ['array']]; } /**