mirror of
https://github.com/symfony/ai.git
synced 2026-03-23 23:42:18 +01:00
[Platform] Generate JSON schema using Validator metadata
This commit is contained in:
@@ -204,6 +204,34 @@ For PHP backed enums, automatic validation without requiring any :class:`Symfony
|
||||
|
||||
This eliminates the need for manual ``#[With(enum: [...])]`` attributes when using PHP's native backed enum types.
|
||||
|
||||
Using Symfony Validator
|
||||
.......................
|
||||
|
||||
If you have `symfony/validator` installed, you can also use validation constraints for schema generation::
|
||||
|
||||
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class Person
|
||||
{
|
||||
#[Assert\Length(max: 255)]
|
||||
public string $name;
|
||||
|
||||
#[Assert\Range(min: 18)]
|
||||
public int $age;
|
||||
}
|
||||
|
||||
#[AsTool('my_person_lookup_tool', 'Example tool to lookup a person.')]
|
||||
final class MyTool
|
||||
{
|
||||
public function __invoke(Person $person): string
|
||||
{
|
||||
// do the lookup ...
|
||||
}
|
||||
}
|
||||
|
||||
This replaces the need to manually define the schema using ``#[With(...)]``, though it's possible to use both if needed.
|
||||
|
||||
.. note::
|
||||
|
||||
Please be aware, that this is only converted in a JSON Schema for the LLM to respect, but not validated by Symfony AI itself.
|
||||
|
||||
@@ -101,7 +101,8 @@
|
||||
"symfony/ai-weaviate-store": "^0.6",
|
||||
"symfony/expression-language": "^7.3|^8.0",
|
||||
"symfony/security-core": "^7.3|^8.0",
|
||||
"symfony/translation": "^7.3|^8.0"
|
||||
"symfony/translation": "^7.3|^8.0",
|
||||
"symfony/validator": "^7.3|^8.0"
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"autoload": {
|
||||
|
||||
@@ -64,6 +64,7 @@ use Symfony\AI\Platform\Contract\JsonSchema\Describer\MethodDescriber;
|
||||
use Symfony\AI\Platform\Contract\JsonSchema\Describer\PropertyInfoDescriber;
|
||||
use Symfony\AI\Platform\Contract\JsonSchema\Describer\SerializerDescriber;
|
||||
use Symfony\AI\Platform\Contract\JsonSchema\Describer\TypeInfoDescriber;
|
||||
use Symfony\AI\Platform\Contract\JsonSchema\Describer\ValidatorConstraintsDescriber;
|
||||
use Symfony\AI\Platform\Contract\JsonSchema\Describer\WithAttributeDescriber;
|
||||
use Symfony\AI\Platform\Contract\JsonSchema\Factory as SchemaFactory;
|
||||
use Symfony\AI\Platform\EventListener\TemplateRendererListener;
|
||||
@@ -180,6 +181,11 @@ return static function (ContainerConfigurator $container): void {
|
||||
service('serializer.mapping.class_metadata_factory')->ignoreOnInvalid(),
|
||||
])
|
||||
->tag('ai.platform.json_schema.describer')
|
||||
->set('ai.platform.json_schema.describer.validator', ValidatorConstraintsDescriber::class)
|
||||
->args([
|
||||
service('validator')->nullOnInvalid(),
|
||||
])
|
||||
->tag('ai.platform.json_schema.describer')
|
||||
->set('ai.platform.json_schema.describer.with_attribute', WithAttributeDescriber::class)
|
||||
->tag('ai.platform.json_schema.describer')
|
||||
->set('ai.platform.json_schema.describer', Describer::class)
|
||||
|
||||
@@ -139,6 +139,7 @@ use Symfony\Component\HttpClient\HttpClient;
|
||||
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use Symfony\Component\Translation\TranslatableMessage;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
@@ -370,6 +371,10 @@ final class AiBundle extends AbstractBundle
|
||||
$builder->removeDefinition('ai.command.setup_message_store');
|
||||
$builder->removeDefinition('ai.command.drop_message_store');
|
||||
}
|
||||
|
||||
if (!ContainerBuilder::willBeAvailable('symfony/validator', ValidatorInterface::class, ['symfony/ai-bundle'])) {
|
||||
$builder->removeDefinition('ai.platform.json_schema.describer.validator');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -163,12 +163,11 @@ class AiBundleTest extends TestCase
|
||||
|
||||
$this->assertFalse($container->hasDefinition('ai.command.setup_store'));
|
||||
$this->assertFalse($container->hasDefinition('ai.command.drop_store'));
|
||||
$this->assertSame([
|
||||
'ai.command.setup_store' => true,
|
||||
'ai.command.drop_store' => true,
|
||||
'ai.command.setup_message_store' => true,
|
||||
'ai.command.drop_message_store' => true,
|
||||
], $container->getRemovedIds());
|
||||
$removedIds = $container->getRemovedIds();
|
||||
$this->assertArrayHasKey('ai.command.setup_store', $removedIds);
|
||||
$this->assertArrayHasKey('ai.command.drop_store', $removedIds);
|
||||
$this->assertArrayHasKey('ai.command.setup_message_store', $removedIds);
|
||||
$this->assertArrayHasKey('ai.command.drop_message_store', $removedIds);
|
||||
}
|
||||
|
||||
public function testMessageStoreCommandsArentDefinedWithoutMessageStore()
|
||||
@@ -185,12 +184,11 @@ class AiBundleTest extends TestCase
|
||||
|
||||
$this->assertFalse($container->hasDefinition('ai.command.setup_message_store'));
|
||||
$this->assertFalse($container->hasDefinition('ai.command.drop_message_store'));
|
||||
$this->assertSame([
|
||||
'ai.command.setup_store' => true,
|
||||
'ai.command.drop_store' => true,
|
||||
'ai.command.setup_message_store' => true,
|
||||
'ai.command.drop_message_store' => true,
|
||||
], $container->getRemovedIds());
|
||||
$removedIds = $container->getRemovedIds();
|
||||
$this->assertArrayHasKey('ai.command.setup_store', $removedIds);
|
||||
$this->assertArrayHasKey('ai.command.drop_store', $removedIds);
|
||||
$this->assertArrayHasKey('ai.command.setup_message_store', $removedIds);
|
||||
$this->assertArrayHasKey('ai.command.drop_message_store', $removedIds);
|
||||
}
|
||||
|
||||
public function testStoreCommandsAreDefined()
|
||||
|
||||
@@ -6,6 +6,7 @@ CHANGELOG
|
||||
|
||||
* Add reranking support via `RerankingResult`, `RerankingEntry`, and `Capability::RERANKING`
|
||||
* Add `description` and `example` properties to `#[With]` attribute
|
||||
* Generate JSON schema from Symfony Validator constraints when available
|
||||
|
||||
0.6
|
||||
---
|
||||
|
||||
@@ -74,8 +74,11 @@
|
||||
"symfony/finder": "^7.3|^8.0",
|
||||
"symfony/http-client": "^7.3|^8.0",
|
||||
"symfony/http-client-contracts": "^3.5",
|
||||
"symfony/intl": "^7.3|^8.0",
|
||||
"symfony/process": "^7.3|^8.0",
|
||||
"symfony/var-dumper": "^7.3|^8.0"
|
||||
"symfony/validator": "^7.3|^8.0",
|
||||
"symfony/var-dumper": "^7.3|^8.0",
|
||||
"symfony/yaml": "^7.3|^8.0"
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"autoload": {
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace Symfony\AI\Platform\Contract\JsonSchema\Describer;
|
||||
|
||||
use Symfony\AI\Platform\Contract\JsonSchema\Subject\ObjectSubject;
|
||||
use Symfony\AI\Platform\Contract\JsonSchema\Subject\PropertySubject;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
final class Describer implements ObjectDescriberInterface, PropertyDescriberInterface
|
||||
{
|
||||
@@ -22,17 +23,26 @@ final class Describer implements ObjectDescriberInterface, PropertyDescriberInte
|
||||
private readonly iterable $propertyDescribers;
|
||||
|
||||
/**
|
||||
* @param iterable<ObjectDescriberInterface|PropertyDescriberInterface> $describers
|
||||
* @param iterable<ObjectDescriberInterface|PropertyDescriberInterface>|null $describers
|
||||
*/
|
||||
public function __construct(
|
||||
iterable $describers = [
|
||||
new SerializerDescriber(),
|
||||
new TypeInfoDescriber(),
|
||||
new MethodDescriber(),
|
||||
new PropertyInfoDescriber(),
|
||||
new WithAttributeDescriber(),
|
||||
],
|
||||
?iterable $describers = null,
|
||||
) {
|
||||
if (null === $describers) {
|
||||
$describers = [
|
||||
new SerializerDescriber(),
|
||||
new TypeInfoDescriber(),
|
||||
new MethodDescriber(),
|
||||
new PropertyInfoDescriber(),
|
||||
];
|
||||
|
||||
if (class_exists(ValidatorInterface::class)) {
|
||||
$describers[] = new ValidatorConstraintsDescriber();
|
||||
}
|
||||
|
||||
$describers[] = new WithAttributeDescriber();
|
||||
}
|
||||
|
||||
$objectDescribers = $propertyDescribers = [];
|
||||
|
||||
foreach ($describers as $describer) {
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Platform\Contract\JsonSchema\Describer;
|
||||
|
||||
use Symfony\AI\Platform\Contract\JsonSchema\Factory;
|
||||
use Symfony\AI\Platform\Contract\JsonSchema\Subject\PropertySubject;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Mapping\ClassMetadataInterface;
|
||||
use Symfony\Component\Validator\Validation;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
/**
|
||||
* @phpstan-import-type JsonSchema from Factory
|
||||
*
|
||||
* @author Valtteri R <valtzu@gmail.com>
|
||||
*/
|
||||
final class ValidatorConstraintsDescriber implements PropertyDescriberInterface
|
||||
{
|
||||
private readonly ValidatorInterface $validator;
|
||||
|
||||
public function __construct(
|
||||
?ValidatorInterface $validator = null,
|
||||
) {
|
||||
$this->validator = $validator ?? Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
|
||||
}
|
||||
|
||||
public function describeProperty(PropertySubject $subject, ?array &$schema): void
|
||||
{
|
||||
$reflector = $subject->getReflector();
|
||||
if ($reflector instanceof \ReflectionParameter) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var ClassMetadataInterface $classMetadata */
|
||||
$classMetadata = $this->validator->getMetadataFor($reflector->class);
|
||||
$propertyMetadata = $classMetadata->getPropertyMetadata($subject->getName());
|
||||
|
||||
foreach ($propertyMetadata as $metadata) {
|
||||
foreach ($metadata->getConstraints() as $constraint) {
|
||||
$this->applyConstraints($constraint, $schema, $reflector->class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*/
|
||||
private function applyConstraints(Constraint $constraint, ?array &$schema, string $class): void
|
||||
{
|
||||
match (true) {
|
||||
$constraint instanceof Assert\All => $this->describeAll($schema, $constraint, $class),
|
||||
$constraint instanceof Assert\AtLeastOneOf => $this->describeAtLeastOneOf($schema, $constraint, $class),
|
||||
$constraint instanceof Assert\Bic => $this->appendDescription('Business Identifier Code (BIC).', $schema),
|
||||
$constraint instanceof Assert\Blank => $this->describeBlank($schema),
|
||||
$constraint instanceof Assert\Choice => $this->describeChoice($schema, $constraint, $class),
|
||||
$constraint instanceof Assert\Cidr => $this->describeCidr($schema, $constraint),
|
||||
$constraint instanceof Assert\Collection => $this->describeCollection($schema, $constraint, $class),
|
||||
$constraint instanceof Assert\Compound => $this->describeCompound($schema, $constraint, $class),
|
||||
$constraint instanceof Assert\Count => $this->describeCount($schema, $constraint),
|
||||
$constraint instanceof Assert\Country => $this->describeCountry($schema, $constraint),
|
||||
$constraint instanceof Assert\CssColor => $this->appendDescription('CSS color in one of the following formats: '.implode(', ', $constraint->formats), $schema),
|
||||
$constraint instanceof Assert\Currency => $this->describeCurrency($schema),
|
||||
$constraint instanceof Assert\Date => $schema['format'] = 'date',
|
||||
$constraint instanceof Assert\DateTime => $schema['format'] = 'date-time',
|
||||
$constraint instanceof Assert\DivisibleBy => $this->describeDivisibleBy($schema, $constraint),
|
||||
$constraint instanceof Assert\Email => $schema['format'] = 'email',
|
||||
$constraint instanceof Assert\EqualTo, $constraint instanceof Assert\IdenticalTo => $this->describeEqualTo($schema, $constraint),
|
||||
$constraint instanceof Assert\Expression => $this->appendDescription(\sprintf('Must match Symfony Expression Language rule: "%s"', $constraint->expression), $schema),
|
||||
$constraint instanceof Assert\ExpressionSyntax => $this->appendDescription('Syntax: Symfony Expression Language.'.($constraint->allowedVariables ? ' Available variables: '.implode(', ', $constraint->allowedVariables) : ''), $schema),
|
||||
$constraint instanceof Assert\GreaterThanOrEqual => $this->describeLowerBound($schema, $constraint, false),
|
||||
$constraint instanceof Assert\GreaterThan => $this->describeLowerBound($schema, $constraint, true),
|
||||
$constraint instanceof Assert\Hostname => $schema['format'] = 'hostname',
|
||||
$constraint instanceof Assert\Iban => $this->appendDescription('IBAN without spaces or other separator characters.', $schema),
|
||||
$constraint instanceof Assert\Ip => $this->describeIp($schema, $constraint),
|
||||
$constraint instanceof Assert\IsFalse => $schema['const'] = false,
|
||||
$constraint instanceof Assert\IsNull => $this->describeIsNull($schema),
|
||||
$constraint instanceof Assert\IsTrue => $schema['const'] = true,
|
||||
$constraint instanceof Assert\Json => $schema['contentMediaType'] = 'application/json',
|
||||
$constraint instanceof Assert\Language => $this->describeLanguage($schema, $constraint),
|
||||
$constraint instanceof Assert\Length => $this->describeLength($schema, $constraint),
|
||||
$constraint instanceof Assert\LessThanOrEqual => $this->describeUpperBound($schema, $constraint, false),
|
||||
$constraint instanceof Assert\LessThan => $this->describeUpperBound($schema, $constraint, true),
|
||||
$constraint instanceof Assert\Locale => $schema['pattern'] = '^[a-z]{2}([_-][A-Z]{2})?$',
|
||||
$constraint instanceof Assert\MacAddress => $this->appendDescription('MAC address, accepted type: '.$constraint->type.'.', $schema),
|
||||
$constraint instanceof Assert\NotBlank => $this->describeNotBlank($schema, $constraint),
|
||||
$constraint instanceof Assert\NotNull => $this->describeNotNull($schema),
|
||||
$constraint instanceof Assert\NotEqualTo, $constraint instanceof Assert\NotIdenticalTo => $this->describeNotEqualTo($schema, $constraint),
|
||||
$constraint instanceof Assert\Range => $this->describeRange($schema, $constraint),
|
||||
$constraint instanceof Assert\Regex => $this->describeRegex($schema, $constraint),
|
||||
$constraint instanceof Assert\Time => $this->describeTime($schema, $constraint),
|
||||
$constraint instanceof Assert\Timezone => $this->appendDescription('Timezone in "Region/City" format.', $schema),
|
||||
$constraint instanceof Assert\Type => $this->describeType($schema, $constraint),
|
||||
$constraint instanceof Assert\Ulid => $this->describeUlid($schema, $constraint),
|
||||
$constraint instanceof Assert\Unique => $schema['uniqueItems'] = true,
|
||||
$constraint instanceof Assert\Url => $schema['format'] = 'uri',
|
||||
$constraint instanceof Assert\Uuid => $schema['format'] = 'uuid',
|
||||
$constraint instanceof Assert\Week => $schema['pattern'] = '^[0-9]{4}W[0-9]{2}$',
|
||||
$constraint instanceof Assert\WordCount => $this->describeWordCount($schema, $constraint),
|
||||
$constraint instanceof Assert\Xml => $schema['contentMediaType'] = 'application/xml',
|
||||
$constraint instanceof Assert\Yaml => $schema['contentMediaType'] = 'application/yaml',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*
|
||||
* @param-out JsonSchema|array<string, mixed> $schema
|
||||
*/
|
||||
private function describeRegex(?array &$schema, Assert\Regex $constraint): void
|
||||
{
|
||||
$schema['pattern'] = $constraint->getHtmlPattern();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*
|
||||
* @param-out JsonSchema|array<string, mixed> $schema
|
||||
*/
|
||||
private function describeNotBlank(?array &$schema, Assert\NotBlank $constraint): void
|
||||
{
|
||||
$schema['nullable'] = $constraint->allowNull;
|
||||
|
||||
if ($this->containsType($schema, 'string')) {
|
||||
$schema['minLength'] = 1;
|
||||
}
|
||||
|
||||
if ($this->containsType($schema, 'object')) {
|
||||
$schema['minProperties'] = 1;
|
||||
}
|
||||
|
||||
if ($this->containsType($schema, 'array')) {
|
||||
$schema['minItems'] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*
|
||||
* @param-out JsonSchema|array<string, mixed> $schema
|
||||
*/
|
||||
private function describeBlank(?array &$schema): void
|
||||
{
|
||||
$schema['nullable'] = true;
|
||||
|
||||
if ($this->containsType($schema, 'string')) {
|
||||
$schema['maxLength'] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*/
|
||||
private function describeNotEqualTo(?array &$schema, Assert\NotEqualTo|Assert\NotIdenticalTo $constraint): void
|
||||
{
|
||||
if ($constraint->propertyPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema['not']['enum'][] = $constraint->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*/
|
||||
private function describeEqualTo(?array &$schema, Assert\EqualTo|Assert\IdenticalTo $constraint): void
|
||||
{
|
||||
if ($constraint->propertyPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema['const'] = $constraint->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*/
|
||||
private function describeChoice(?array &$schema, Assert\Choice $constraint, string $class): void
|
||||
{
|
||||
if ($constraint->callback) {
|
||||
if (\is_callable($choices = [$class, $constraint->callback]) || \is_callable($choices = $constraint->callback)) {
|
||||
$choices = $choices();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
$choices = $constraint->choices;
|
||||
}
|
||||
|
||||
if (!\is_array($choices)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($constraint->multiple) {
|
||||
$schema['items']['enum'] = $choices;
|
||||
if (null !== $constraint->min) {
|
||||
$schema['minItems'] = $constraint->min;
|
||||
}
|
||||
if (null !== $constraint->max) {
|
||||
$schema['maxItems'] = $constraint->max;
|
||||
}
|
||||
} else {
|
||||
if ($constraint->match) {
|
||||
$schema['enum'] = $choices;
|
||||
} else {
|
||||
foreach ($choices as $choice) {
|
||||
if (\in_array($choice, $schema['not']['enum'] ?? [], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$schema['not']['enum'][] = $choice;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*/
|
||||
private function describeLowerBound(?array &$schema, Assert\AbstractComparison $constraint, bool $exclusive): void
|
||||
{
|
||||
if (null !== $constraint->propertyPath || !\is_scalar($constraint->value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_numeric($constraint->value)) {
|
||||
$this->appendDescription('Minimum value: '.$constraint->value, $schema);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$schema['minimum'] = $constraint->value;
|
||||
if ($exclusive) {
|
||||
$schema['exclusiveMinimum'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*/
|
||||
private function describeUpperBound(?array &$schema, Assert\AbstractComparison $constraint, bool $exclusive): void
|
||||
{
|
||||
if (null !== $constraint->propertyPath || !\is_scalar($constraint->value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_numeric($constraint->value)) {
|
||||
$this->appendDescription('Maximum value: '.$constraint->value, $schema);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$schema['maximum'] = $constraint->value;
|
||||
if ($exclusive) {
|
||||
$schema['exclusiveMaximum'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*/
|
||||
private function describeRange(?array &$schema, Assert\Range $constraint): void
|
||||
{
|
||||
if (null === $constraint->minPropertyPath && \is_scalar($constraint->min)) {
|
||||
if (is_numeric($constraint->min)) {
|
||||
$schema['minimum'] = $constraint->min;
|
||||
} else {
|
||||
$this->appendDescription('Minimum value: '.$constraint->min, $schema);
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $constraint->maxPropertyPath && \is_scalar($constraint->max)) {
|
||||
if (is_numeric($constraint->max)) {
|
||||
$schema['maximum'] = $constraint->max;
|
||||
} else {
|
||||
$this->appendDescription('Maximum value: '.$constraint->max, $schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*/
|
||||
private function describeDivisibleBy(?array &$schema, Assert\DivisibleBy $constraint): void
|
||||
{
|
||||
if (null !== $constraint->propertyPath || !is_numeric($constraint->value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema['multipleOf'] = $constraint->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*/
|
||||
private function describeCount(?array &$schema, Assert\Count $constraint): void
|
||||
{
|
||||
if (null !== $constraint->min) {
|
||||
$schema['minItems'] = $constraint->min;
|
||||
}
|
||||
|
||||
if (null !== $constraint->max) {
|
||||
$schema['maxItems'] = $constraint->max;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*/
|
||||
private function describeLength(?array &$schema, Assert\Length $constraint): void
|
||||
{
|
||||
if (null !== $constraint->min) {
|
||||
$schema['minLength'] = $constraint->min;
|
||||
}
|
||||
|
||||
if (null !== $constraint->max) {
|
||||
$schema['maxLength'] = $constraint->max;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*
|
||||
* @param-out JsonSchema|array<string, mixed> $schema
|
||||
*/
|
||||
private function describeTime(?array &$schema, Assert\Time $constraint): void
|
||||
{
|
||||
if ($constraint->withSeconds) {
|
||||
$schema['format'] = 'time';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$schema['pattern'] = '^([01]\d|2[0-3]):[0-5]\d$';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*/
|
||||
private function describeUlid(?array &$schema, Assert\Ulid $constraint): void
|
||||
{
|
||||
match ($constraint->format) {
|
||||
Assert\Ulid::FORMAT_BASE_32 => $schema['pattern'] = '^[0-7][0-9A-HJKMNP-TV-Z]{25}$',
|
||||
Assert\Ulid::FORMAT_BASE_58 => $schema['pattern'] = '^[1-9A-HJ-NP-Za-km-z]{22}$',
|
||||
Assert\Ulid::FORMAT_RFC_4122 => $schema['format'] = 'uuid',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*/
|
||||
private function describeIp(?array &$schema, Assert\Ip $constraint): void
|
||||
{
|
||||
if (str_starts_with($constraint->version, Assert\Ip::V4)) {
|
||||
$schema['format'] = 'ipv4';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (str_starts_with($constraint->version, Assert\Ip::V6)) {
|
||||
$schema['format'] = 'ipv6';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*
|
||||
* @param-out JsonSchema|array<string, mixed> $schema
|
||||
*/
|
||||
private function describeIsNull(?array &$schema): void
|
||||
{
|
||||
$schema['const'] = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*/
|
||||
private function describeType(?array &$schema, Assert\Type $constraint): void
|
||||
{
|
||||
$constraintTypes = \is_array($constraint->type) ? $constraint->type : [$constraint->type];
|
||||
$jsonSchemaTypes = [];
|
||||
|
||||
foreach ($constraintTypes as $constraintType) {
|
||||
$jsonSchemaType = $this->mapConstraintTypeToJsonSchemaType($constraintType);
|
||||
|
||||
if (null !== $jsonSchemaType) {
|
||||
$jsonSchemaTypes[] = $jsonSchemaType;
|
||||
}
|
||||
}
|
||||
|
||||
$jsonSchemaTypes = array_values(array_unique($jsonSchemaTypes));
|
||||
if ([] === $jsonSchemaTypes) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isset($schema['type'])) {
|
||||
$schema['type'] = 1 === \count($jsonSchemaTypes) ? $jsonSchemaTypes[0] : $jsonSchemaTypes;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingTypes = \is_array($schema['type']) ? $schema['type'] : [$schema['type']];
|
||||
$intersectedTypes = array_values(array_intersect($existingTypes, $jsonSchemaTypes));
|
||||
|
||||
if ([] !== $intersectedTypes) {
|
||||
$schema['type'] = 1 === \count($intersectedTypes) ? $intersectedTypes[0] : $intersectedTypes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*/
|
||||
private function containsType(?array $schema, string $type): bool
|
||||
{
|
||||
if (!isset($schema['type'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$types = \is_array($schema['type']) ? $schema['type'] : [$schema['type']];
|
||||
|
||||
return \in_array($type, $types, true);
|
||||
}
|
||||
|
||||
private function mapConstraintTypeToJsonSchemaType(string $constraintType): ?string
|
||||
{
|
||||
return match ($constraintType) {
|
||||
'int', 'integer' => 'integer',
|
||||
'float', 'double', 'real', 'number', 'numeric' => 'number',
|
||||
'bool', 'boolean' => 'boolean',
|
||||
'array', 'list' => 'array',
|
||||
'object' => 'object',
|
||||
'string' => 'string',
|
||||
'null' => 'null',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*
|
||||
* @param-out JsonSchema|array<string, mixed> $schema
|
||||
*/
|
||||
private function appendDescription(string $description, ?array &$schema): void
|
||||
{
|
||||
$schema['description'] ??= '';
|
||||
if ($schema['description']) {
|
||||
$schema['description'] .= "\n";
|
||||
}
|
||||
|
||||
$schema['description'] .= $description;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*
|
||||
* @param-out JsonSchema|array<string, mixed>|null $schema
|
||||
*/
|
||||
private function describeAtLeastOneOf(?array &$schema, Assert\AtLeastOneOf $constraint, string $class): void
|
||||
{
|
||||
foreach ((array) $constraint->constraints as $constraint) {
|
||||
$anyOf = null;
|
||||
$this->applyConstraints($constraint, $anyOf, $class);
|
||||
if ($anyOf) {
|
||||
$schema['anyOf'][] = $anyOf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*
|
||||
* @param-out JsonSchema|array<string, mixed> $schema
|
||||
*/
|
||||
private function describeAll(?array &$schema, Assert\All $constraint, string $class): void
|
||||
{
|
||||
// Since additionalProperties is not supported by all (or none?) platforms, we only support non-assoc arrays here.
|
||||
if (!\in_array('array', (array) ($schema['type'] ??= 'array'), true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ((array) $constraint->constraints as $constraint) {
|
||||
$this->applyConstraints($constraint, $schema['items'], $class);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*
|
||||
* @param-out JsonSchema|array<string, mixed> $schema
|
||||
*/
|
||||
private function describeCollection(?array &$schema, Assert\Collection $constraint, string $class): void
|
||||
{
|
||||
if (!\in_array('object', (array) ($schema['type'] ??= 'object'), true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($constraint->fields as $field => $fieldConstraints) {
|
||||
if (!\is_array($fieldConstraints)) {
|
||||
$fieldConstraints = [$fieldConstraints];
|
||||
}
|
||||
foreach ($fieldConstraints as $fieldConstraint) {
|
||||
if (!$fieldConstraint instanceof Assert\Existence) {
|
||||
$this->applyConstraints($fieldConstraint, $schema['properties'][$field], $class);
|
||||
continue;
|
||||
}
|
||||
|
||||
$nestedConstraints = !\is_array($fieldConstraint->constraints) ? [$fieldConstraint->constraints] : $fieldConstraint->constraints;
|
||||
foreach ($nestedConstraints as $nestedConstraint) {
|
||||
$this->applyConstraints($nestedConstraint, $schema['properties'][$field], $class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$constraint->allowMissingFields) {
|
||||
$schema['required'] = array_values(array_unique(array_merge($schema['required'] ?? [], array_keys($constraint->fields))));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*
|
||||
* @param-out JsonSchema|array<string, mixed>|null $schema
|
||||
*/
|
||||
private function describeCompound(?array &$schema, Assert\Compound $constraint, string $class): void
|
||||
{
|
||||
foreach ($constraint->constraints as $constraint) {
|
||||
$this->applyConstraints($constraint, $schema, $class);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*
|
||||
* @param-out JsonSchema|array<string, mixed> $schema
|
||||
*/
|
||||
private function describeCountry(?array &$schema, Assert\Country $constraint): void
|
||||
{
|
||||
$schema['pattern'] = $constraint->alpha3 ? '^[A-Z]{3}$' : '^[A-Z]{2}$';
|
||||
$this->appendDescription(\sprintf('ISO 3166-1 alpha-%d country code', $constraint->alpha3 ? 3 : 2), $schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*
|
||||
* @param-out JsonSchema|array<string, mixed> $schema
|
||||
*/
|
||||
private function describeLanguage(?array &$schema, Assert\Language $constraint): void
|
||||
{
|
||||
$schema['pattern'] = $constraint->alpha3 ? '^[a-z]{3}$' : '^[a-z]{2}$';
|
||||
$this->appendDescription($constraint->alpha3 ? 'ISO 639-2 (2T) language code' : 'ISO 639-1 language code', $schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*/
|
||||
private function describeWordCount(?array &$schema, Assert\WordCount $constraint): void
|
||||
{
|
||||
$description = match ([null !== $constraint->min, null !== $constraint->max]) {
|
||||
[true, true] => \sprintf('Word count must be between %d and %d.', $constraint->min, $constraint->max),
|
||||
[true, false] => \sprintf('Word count must be at least %d.', $constraint->min),
|
||||
[false, true] => \sprintf('Word count must be no more than %d.', $constraint->max),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (!$description) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->appendDescription($description, $schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*/
|
||||
private function describeCidr(?array &$schema, Assert\Cidr $constraint): void
|
||||
{
|
||||
$ipVersion = match ($constraint->version) {
|
||||
Assert\Ip::V4 => 'IPv4',
|
||||
Assert\Ip::V6 => 'IPv6',
|
||||
Assert\Ip::ALL => 'Any IP',
|
||||
|
||||
Assert\Ip::V4_NO_PUBLIC => 'IPv4 (excl. public)',
|
||||
Assert\Ip::V6_NO_PUBLIC => 'IPv6 (excl. public)',
|
||||
Assert\Ip::ALL_NO_PUBLIC => 'Any IP (excl. public)',
|
||||
|
||||
Assert\Ip::V4_NO_PRIVATE => 'IPv4 (excl. private)',
|
||||
Assert\Ip::V6_NO_PRIVATE => 'IPv6 (excl. private)',
|
||||
Assert\Ip::ALL_NO_PRIVATE => 'Any IP (excl. private)',
|
||||
|
||||
Assert\Ip::V4_NO_RESERVED => 'IPv4 (excl. reserved)',
|
||||
Assert\Ip::V6_NO_RESERVED => 'IPv6 (excl. reserved)',
|
||||
Assert\Ip::ALL_NO_RESERVED => 'Any IP (excl. reserved)',
|
||||
|
||||
Assert\Ip::V4_ONLY_PUBLIC => 'Public IPv4',
|
||||
Assert\Ip::V6_ONLY_PUBLIC => 'Public IPv6',
|
||||
Assert\Ip::ALL_ONLY_PUBLIC => 'Any public IP',
|
||||
|
||||
Assert\Ip::V4_ONLY_PRIVATE => 'Private IPv4',
|
||||
Assert\Ip::V6_ONLY_PRIVATE => 'Private IPv6',
|
||||
Assert\Ip::ALL_ONLY_PRIVATE => 'Any private IP',
|
||||
|
||||
Assert\Ip::V4_ONLY_RESERVED => 'Reserved IPv4',
|
||||
Assert\Ip::V6_ONLY_RESERVED => 'Reserved IPv6',
|
||||
Assert\Ip::ALL_ONLY_RESERVED => 'Any reserved IP',
|
||||
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (!$ipVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->appendDescription($ipVersion.' address.', $schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*
|
||||
* @param-out JsonSchema|array<string, mixed> $schema
|
||||
*/
|
||||
private function describeNotNull(?array &$schema): void
|
||||
{
|
||||
$schema['nullable'] = false;
|
||||
|
||||
if (\in_array($schema['type'] ?? null, ['null', ['null']], true)) {
|
||||
unset($schema['type']);
|
||||
} elseif (\in_array('null', (array) ($schema['type'] ?? []), true)) {
|
||||
$schema['type'] = array_values(array_filter($schema['type'], static fn ($item) => 'null' !== $item));
|
||||
|
||||
if (1 === \count($schema['type'])) {
|
||||
[$schema['type']] = $schema['type'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $schema
|
||||
*
|
||||
* @param-out JsonSchema|array<string, mixed> $schema
|
||||
*/
|
||||
private function describeCurrency(?array &$schema): void
|
||||
{
|
||||
$schema['pattern'] = '^[A-Z]{3}$';
|
||||
$this->appendDescription('ISO 4217 currency code', $schema);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Platform\Tests\Contract\JsonSchema\Describer;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\RequiresMethod;
|
||||
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\AI\Platform\Contract\JsonSchema\Describer\ValidatorConstraintsDescriber;
|
||||
use Symfony\AI\Platform\Contract\JsonSchema\Factory;
|
||||
use Symfony\AI\Platform\Contract\JsonSchema\Subject\PropertySubject;
|
||||
use Symfony\AI\Platform\Tests\Fixtures\StructuredOutput\ValidatorConstraintsFixture;
|
||||
use Symfony\AI\Platform\Tests\Fixtures\StructuredOutput\ValidatorConstraintsIntlFixture;
|
||||
use Symfony\Component\Intl\Countries;
|
||||
use Symfony\Component\Validator\Constraints\Xml;
|
||||
use Symfony\Component\Validator\Validation;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
* @phpstan-import-type JsonSchema from Factory
|
||||
*/
|
||||
final class ValidatorConstraintsDescriberTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $initialSchema
|
||||
* @param JsonSchema|array<string, mixed> $expectedSchema
|
||||
*/
|
||||
#[DataProvider('provideDescribeCases')]
|
||||
#[RequiresMethod(Yaml::class, 'parse')]
|
||||
public function testDescribe(string $property, ?array $initialSchema, array $expectedSchema)
|
||||
{
|
||||
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
|
||||
$describer = new ValidatorConstraintsDescriber($validator);
|
||||
$propertyReflection = new \ReflectionProperty(ValidatorConstraintsFixture::class, $property);
|
||||
|
||||
$schema = $initialSchema;
|
||||
$describer->describeProperty(new PropertySubject($property, $propertyReflection), $schema);
|
||||
|
||||
$this->assertSame($expectedSchema, $schema);
|
||||
}
|
||||
|
||||
#[RequiresMethod(Xml::class, '__construct')]
|
||||
#[RequiresPhpExtension('simplexml')]
|
||||
public function testDescribeXml()
|
||||
{
|
||||
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
|
||||
$describer = new ValidatorConstraintsDescriber($validator);
|
||||
$propertyReflection = new \ReflectionProperty(ValidatorConstraintsFixture::class, 'xml');
|
||||
|
||||
$schema = null;
|
||||
$describer->describeProperty(new PropertySubject('xml', $propertyReflection), $schema);
|
||||
|
||||
$this->assertSame(['contentMediaType' => 'application/xml'], $schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{0: string, 1: array<mixed>|null, 2: array<mixed>}>
|
||||
*/
|
||||
public static function provideDescribeCases(): iterable
|
||||
{
|
||||
yield 'AtLeastOneOf' => ['atLeastOneOf', null, ['anyOf' => [['const' => 'a'], ['type' => 'integer']]]];
|
||||
yield 'All' => ['all', null, ['type' => 'array', 'items' => ['maxLength' => 255]]];
|
||||
yield 'All, non-array type' => ['all', ['type' => 'string'], ['type' => 'string']];
|
||||
yield 'Blank string' => ['blankString', ['type' => 'string'], ['type' => 'string', 'nullable' => true, 'maxLength' => 0]];
|
||||
yield 'Cidr' => ['cidr', null, ['description' => 'Any IP address.']];
|
||||
yield 'Collection' => ['collection', null, ['type' => 'object', 'properties' => ['a' => ['const' => 'hello'], 'b' => ['const' => 5]], 'required' => ['a', 'b']]];
|
||||
yield 'Collection, non-object type ' => ['collection', ['type' => 'array'], ['type' => 'array']];
|
||||
yield 'Count and unique array' => ['countedArray', null, ['minItems' => 2, 'maxItems' => 4, 'uniqueItems' => true]];
|
||||
yield 'CssColor' => ['cssColor', ['description' => 'Background color.'], ['description' => "Background color.\nCSS color in one of the following formats: hex_short, hex_long"]];
|
||||
yield 'Choice string' => ['choiceString', null, ['enum' => ['a', 'b']]];
|
||||
yield 'Choice array' => ['choiceArray', ['type' => 'array', 'items' => ['type' => 'string']], ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['x', 'y']], 'minItems' => 1, 'maxItems' => 2]];
|
||||
yield 'Choice callback' => ['choiceCallback', ['type' => 'integer'], ['type' => 'integer', 'enum' => [1, 2, 3]]];
|
||||
yield 'Choice match=false' => ['choiceInverse', ['not' => ['enum' => [1]]], ['not' => ['enum' => [1, 2, 3]]]];
|
||||
yield 'Date format' => ['date', null, ['format' => 'date']];
|
||||
yield 'DateTime format' => ['dateTime', null, ['format' => 'date-time']];
|
||||
yield 'Email format' => ['email', null, ['format' => 'email']];
|
||||
yield 'EqualTo' => ['equalTo', null, ['const' => 'foo']];
|
||||
yield 'Expression' => ['expression', null, ['description' => 'Must match Symfony Expression Language rule: "this.expression != null"']];
|
||||
yield 'ExpressionSyntax' => ['expressionSyntax', null, ['description' => 'Syntax: Symfony Expression Language. Available variables: foo, bar']];
|
||||
yield 'Hostname format' => ['hostname', null, ['format' => 'hostname']];
|
||||
yield 'IBAN' => ['iban', null, ['description' => 'IBAN without spaces or other separator characters.']];
|
||||
yield 'IPv4 format' => ['ipv4', null, ['format' => 'ipv4']];
|
||||
yield 'IPv6 format' => ['ipv6', null, ['format' => 'ipv6']];
|
||||
yield 'IsFalse const' => ['isFalse', null, ['const' => false]];
|
||||
yield 'IsNull const' => ['isNull', ['type' => ['string', 'null']], ['type' => ['string', 'null'], 'const' => null]];
|
||||
yield 'IsTrue const' => ['isTrue', null, ['const' => true]];
|
||||
yield 'Json' => ['json', null, ['contentMediaType' => 'application/json']];
|
||||
yield 'Length string' => ['lengthString', null, ['minLength' => 2, 'maxLength' => 4]];
|
||||
yield 'MacAddress' => ['macAddress', null, ['description' => 'MAC address, accepted type: all.']];
|
||||
yield 'Negative or zero' => ['negativeNumber', null, ['maximum' => 0]];
|
||||
yield 'NotBlank string' => ['notBlankString', ['type' => 'string'], ['type' => 'string', 'nullable' => false, 'minLength' => 1]];
|
||||
yield 'NotNull nullable false' => ['notNull', null, ['nullable' => false]];
|
||||
yield 'NotNull with null type' => ['notNull', ['type' => ['string', 'null']], ['type' => 'string', 'nullable' => false]];
|
||||
yield 'NotEqualTo' => ['notEqualTo', null, ['not' => ['enum' => ['bar']]]];
|
||||
yield 'Numeric range' => ['numberRange', null, ['multipleOf' => 3, 'minimum' => 10, 'exclusiveMinimum' => true, 'maximum' => 100]];
|
||||
yield 'Positive' => ['positiveNumber', null, ['minimum' => 0, 'exclusiveMinimum' => true]];
|
||||
yield 'Range constraint' => ['rangedNumber', null, ['minimum' => 5, 'maximum' => 15]];
|
||||
yield 'Regex string' => ['regexString', null, ['pattern' => '[a-z]+']];
|
||||
yield 'Time pattern' => ['time', null, ['pattern' => '^([01]\d|2[0-3]):[0-5]\d$']];
|
||||
yield 'Timezone' => ['timezone', null, ['description' => 'Timezone in "Region/City" format.']];
|
||||
yield 'Type constraint narrows schema type' => ['typedByConstraint', ['type' => ['string', 'null', 'integer']], ['type' => ['string', 'null']]];
|
||||
yield 'Ulid pattern' => ['ulid', null, ['pattern' => '^[0-7][0-9A-HJKMNP-TV-Z]{25}$']];
|
||||
yield 'Url format' => ['url', null, ['format' => 'uri']];
|
||||
yield 'Uuid format' => ['uuid', null, ['format' => 'uuid']];
|
||||
yield 'Week' => ['week', null, ['pattern' => '^[0-9]{4}W[0-9]{2}$']];
|
||||
yield 'WordCount between' => ['wordCountBetween', null, ['description' => 'Word count must be between 10 and 20.']];
|
||||
yield 'WordCount minimum' => ['wordCountMinimum', null, ['description' => 'Word count must be at least 10.']];
|
||||
yield 'WordCount maximum' => ['wordCountMaximum', null, ['description' => 'Word count must be no more than 20.']];
|
||||
yield 'Yaml' => ['yaml', null, ['contentMediaType' => 'application/yaml']];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JsonSchema|array<string, mixed>|null $initialSchema
|
||||
* @param JsonSchema|array<string, mixed> $expectedSchema
|
||||
*/
|
||||
#[DataProvider('describeIntlProvider')]
|
||||
#[RequiresMethod(Countries::class, 'exists')]
|
||||
public function testDescribeIntl(string $property, ?array $initialSchema, array $expectedSchema)
|
||||
{
|
||||
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
|
||||
$describer = new ValidatorConstraintsDescriber($validator);
|
||||
$propertyReflection = new \ReflectionProperty(ValidatorConstraintsIntlFixture::class, $property);
|
||||
|
||||
$schema = $initialSchema;
|
||||
$describer->describeProperty(new PropertySubject($property, $propertyReflection), $schema);
|
||||
|
||||
$this->assertSame($expectedSchema, $schema);
|
||||
}
|
||||
|
||||
public static function describeIntlProvider(): iterable
|
||||
{
|
||||
yield 'Country alpha-2' => ['countryAlpha2', null, ['pattern' => '^[A-Z]{2}$', 'description' => 'ISO 3166-1 alpha-2 country code']];
|
||||
yield 'Country alpha-3' => ['countryAlpha3', null, ['pattern' => '^[A-Z]{3}$', 'description' => 'ISO 3166-1 alpha-3 country code']];
|
||||
yield 'Language alpha-2' => ['languageAlpha2', null, ['pattern' => '^[a-z]{2}$', 'description' => 'ISO 639-1 language code']];
|
||||
yield 'Language alpha-3' => ['languageAlpha3', null, ['pattern' => '^[a-z]{3}$', 'description' => 'ISO 639-2 (2T) language code']];
|
||||
yield 'Currency' => ['currency', null, ['pattern' => '^[A-Z]{3}$', 'description' => 'ISO 4217 currency code']];
|
||||
yield 'Locale' => ['locale', null, ['pattern' => '^[a-z]{2}([_-][A-Z]{2})?$']];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Platform\Tests\Fixtures\StructuredOutput;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
final class ValidatorConstraintsFixture
|
||||
{
|
||||
/** @var array<string> */
|
||||
#[Assert\All(new Assert\Length(max: 255))]
|
||||
public array $all;
|
||||
|
||||
#[Assert\AtLeastOneOf([new Assert\EqualTo('a'), new Assert\Type('int')])]
|
||||
public string $atLeastOneOf;
|
||||
|
||||
#[Assert\Blank]
|
||||
public string $blankString;
|
||||
|
||||
#[Assert\Cidr]
|
||||
public string $cidr;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
#[Assert\Choice(choices: ['x', 'y'], multiple: true, min: 1, max: 2)]
|
||||
public array $choiceArray = [];
|
||||
|
||||
/**
|
||||
* @var list<int>
|
||||
*/
|
||||
#[Assert\Choice(callback: 'choiceCallback')]
|
||||
public array $choiceCallback;
|
||||
|
||||
#[Assert\Choice(choices: [2, 3], match: false)]
|
||||
public string $choiceInverse;
|
||||
|
||||
#[Assert\Choice(choices: ['a', 'b'])]
|
||||
public string $choiceString;
|
||||
|
||||
/** @var array{a: string, b: int} */
|
||||
#[Assert\Collection([
|
||||
'a' => new Assert\EqualTo('hello'),
|
||||
'b' => new Assert\EqualTo(5),
|
||||
])]
|
||||
public array $collection;
|
||||
|
||||
/** @var list<mixed> */
|
||||
#[Assert\Count(min: 2, max: 4)]
|
||||
#[Assert\Unique]
|
||||
public array $countedArray = [];
|
||||
|
||||
#[Assert\CssColor(formats: [Assert\CssColor::HEX_SHORT, Assert\CssColor::HEX_LONG])]
|
||||
public string $cssColor;
|
||||
|
||||
#[Assert\Date]
|
||||
public string $date;
|
||||
|
||||
#[Assert\DateTime]
|
||||
public string $dateTime;
|
||||
|
||||
#[Assert\Email]
|
||||
public string $email;
|
||||
|
||||
#[Assert\EqualTo('foo')]
|
||||
public string $equalTo;
|
||||
|
||||
#[Assert\ExpressionSyntax(allowedVariables: ['foo', 'bar'])]
|
||||
public string $expressionSyntax;
|
||||
|
||||
#[Assert\Expression('this.expression != null')]
|
||||
public string $expression;
|
||||
|
||||
#[Assert\Hostname]
|
||||
public string $hostname;
|
||||
|
||||
#[Assert\Iban]
|
||||
public string $iban;
|
||||
|
||||
#[Assert\Ip(version: Assert\Ip::V4)]
|
||||
public string $ipv4;
|
||||
|
||||
#[Assert\Ip(version: Assert\Ip::V6)]
|
||||
public string $ipv6;
|
||||
|
||||
#[Assert\IsFalse]
|
||||
public bool $isFalse;
|
||||
|
||||
#[Assert\IsNull]
|
||||
public ?string $isNull = null;
|
||||
|
||||
#[Assert\IsTrue]
|
||||
public bool $isTrue;
|
||||
|
||||
#[Assert\Length(min: 2, max: 4)]
|
||||
public string $lengthString;
|
||||
|
||||
#[Assert\Json]
|
||||
public string $json;
|
||||
|
||||
#[Assert\MacAddress]
|
||||
public string $macAddress;
|
||||
|
||||
#[Assert\NotNull]
|
||||
public ?string $notNull;
|
||||
|
||||
#[Assert\NegativeOrZero]
|
||||
public int $negativeNumber;
|
||||
|
||||
#[Assert\NotBlank]
|
||||
public string $notBlankString;
|
||||
|
||||
#[Assert\NotEqualTo('bar')]
|
||||
public string $notEqualTo;
|
||||
|
||||
#[Assert\DivisibleBy(3)]
|
||||
#[Assert\GreaterThan(10)]
|
||||
#[Assert\LessThanOrEqual(100)]
|
||||
public int $numberRange;
|
||||
|
||||
#[Assert\Positive]
|
||||
public int $positiveNumber;
|
||||
|
||||
#[Assert\Range(min: 5, max: 15)]
|
||||
public int $rangedNumber;
|
||||
|
||||
#[Assert\Regex(pattern: '/^[a-z]+$/')]
|
||||
public string $regexString;
|
||||
|
||||
#[Assert\Time(withSeconds: false)]
|
||||
public string $time;
|
||||
|
||||
#[Assert\Timezone]
|
||||
public string $timezone;
|
||||
|
||||
#[Assert\Type(['string', 'null'])]
|
||||
public mixed $typedByConstraint;
|
||||
|
||||
#[Assert\Ulid(format: Assert\Ulid::FORMAT_BASE_32)]
|
||||
public string $ulid;
|
||||
|
||||
#[Assert\Url]
|
||||
public string $url;
|
||||
|
||||
#[Assert\Uuid]
|
||||
public string $uuid;
|
||||
|
||||
#[Assert\Week]
|
||||
public string $week;
|
||||
|
||||
#[Assert\WordCount(min: 10, max: 20)]
|
||||
public string $wordCountBetween;
|
||||
|
||||
#[Assert\WordCount(max: 20)]
|
||||
public string $wordCountMaximum;
|
||||
|
||||
#[Assert\WordCount(min: 10)]
|
||||
public string $wordCountMinimum;
|
||||
|
||||
#[Assert\Xml]
|
||||
public string $xml;
|
||||
|
||||
#[Assert\Yaml]
|
||||
public string $yaml;
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function choiceCallback(): array
|
||||
{
|
||||
return range(1, 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Platform\Tests\Fixtures\StructuredOutput;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
final class ValidatorConstraintsIntlFixture
|
||||
{
|
||||
#[Assert\Country]
|
||||
public string $countryAlpha2;
|
||||
|
||||
#[Assert\Country(alpha3: true)]
|
||||
public string $countryAlpha3;
|
||||
|
||||
#[Assert\Language]
|
||||
public string $languageAlpha2;
|
||||
|
||||
#[Assert\Language(alpha3: true)]
|
||||
public string $languageAlpha3;
|
||||
|
||||
#[Assert\Locale]
|
||||
public string $locale;
|
||||
|
||||
#[Assert\Currency]
|
||||
public string $currency;
|
||||
}
|
||||
Reference in New Issue
Block a user