[Platform] Generate JSON schema using Validator metadata

This commit is contained in:
valtzu
2026-02-15 23:00:23 +02:00
parent 81a1cb462d
commit fc8952a2cd
12 changed files with 1095 additions and 22 deletions

View File

@@ -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.

View File

@@ -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": {

View File

@@ -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)

View File

@@ -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');
}
}
/**

View File

@@ -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()

View File

@@ -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
---

View File

@@ -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": {

View File

@@ -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) {

View File

@@ -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);
}
}

View File

@@ -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})?$']];
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}