Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 2.7.0

* JSON Schema: Add support for generating property schema with Collection restriction (#4182)
* JSON Schema: Add support for generating property schema format for Url and Hostname (#4185)
* JSON Schema: Add support for generating property schema with Count restriction (#4186)
* JSON Schema: Manage Compound constraint when generating property metadata (#4180)
Expand Down
5 changes: 5 additions & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/validator.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
<tag name="api_platform.metadata.property_schema_restriction"/>
</service>

<service id="api_platform.metadata.property_schema.collection_restriction" class="ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaCollectionRestriction" public="false">
<argument type="tagged" tag="api_platform.metadata.property_schema_restriction" />
<tag name="api_platform.metadata.property_schema_restriction"/>
</service>

<service id="api_platform.metadata.property_schema.count_restriction" class="ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaCountRestriction" public="false">
<tag name="api_platform.metadata.property_schema_restriction"/>
</service>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction;

use ApiPlatform\Core\Metadata\Property\PropertyMetadata;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\Collection;
use Symfony\Component\Validator\Constraints\Optional;
use Symfony\Component\Validator\Constraints\Required;

/**
* @author Tomas Norkūnas <norkunas.tom@gmail.com>
*/
final class PropertySchemaCollectionRestriction implements PropertySchemaRestrictionMetadataInterface
{
/**
* @var iterable<PropertySchemaRestrictionMetadataInterface>
*/
private $restrictionsMetadata;

/**
* @param iterable<PropertySchemaRestrictionMetadataInterface> $restrictionsMetadata
*/
public function __construct(iterable $restrictionsMetadata = [])
{
$this->restrictionsMetadata = $restrictionsMetadata;
}

/**
* {@inheritdoc}
*
* @param Collection $constraint
*/
public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array
{
$restriction = [
'type' => 'object',
'properties' => [],
'additionalProperties' => $constraint->allowExtraFields,
];
$required = [];

foreach ($constraint->fields as $field => $baseConstraint) {
/** @var Required|Optional $baseConstraint */
if ($baseConstraint instanceof Required) {
$required[] = $field;
}

$restriction['properties'][$field] = $this->mergeConstraintRestrictions($baseConstraint, $propertyMetadata);
}

if ($required) {
$restriction['required'] = $required;
}

return $restriction;
}

/**
* {@inheritdoc}
*/
public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool
{
return $constraint instanceof Collection;
}

/**
* @param Required|Optional $constraint
*/
private function mergeConstraintRestrictions(Constraint $constraint, PropertyMetadata $propertyMetadata): array
{
$propertyRestrictions = [];
$nestedConstraints = method_exists($constraint, 'getNestedContraints') ? $constraint->getNestedContraints() : $constraint->constraints;

foreach ($nestedConstraints as $nestedConstraint) {
foreach ($this->restrictionsMetadata as $restrictionMetadata) {
if ($restrictionMetadata->supports($nestedConstraint, $propertyMetadata) && !empty($nestedConstraintRestriction = $restrictionMetadata->create($nestedConstraint, $propertyMetadata))) {
$propertyRestrictions[] = $nestedConstraintRestriction;
}
}
}

return array_merge([], ...$propertyRestrictions);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1358,6 +1358,7 @@ private function getBaseContainerBuilderProphecyWithoutDefaultMetadataLoading(ar
'api_platform.metadata.property.metadata_factory.annotation',
'api_platform.metadata.property.metadata_factory.validator',
'api_platform.metadata.property_schema.choice_restriction',
'api_platform.metadata.property_schema.collection_restriction',
'api_platform.metadata.property_schema.count_restriction',
'api_platform.metadata.property_schema.length_restriction',
'api_platform.metadata.property_schema.one_of_restriction',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Metadata\Property\Restriction;

use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaCollectionRestriction;
use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaFormat;
use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLengthRestriction;
use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRegexRestriction;
use ApiPlatform\Core\Metadata\Property\PropertyMetadata;
use ApiPlatform\Core\Tests\ProphecyTrait;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\Collection;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotNull;
use Symfony\Component\Validator\Constraints\Optional;
use Symfony\Component\Validator\Constraints\Positive;
use Symfony\Component\Validator\Constraints\Regex;
use Symfony\Component\Validator\Constraints\Required;

/**
* @author Tomas Norkūnas <norkunas.tom@gmail.com>
*/
final class PropertySchemaCollectionRestrictionTest extends TestCase
{
use ProphecyTrait;

private $propertySchemaCollectionRestriction;

protected function setUp(): void
{
$this->propertySchemaCollectionRestriction = new PropertySchemaCollectionRestriction([
new PropertySchemaLengthRestriction(),
new PropertySchemaRegexRestriction(),
new PropertySchemaFormat(),
new PropertySchemaCollectionRestriction(),
]);
}

/**
* @dataProvider supportsProvider
*/
public function testSupports(Constraint $constraint, PropertyMetadata $propertyMetadata, bool $expectedResult): void
{
self::assertSame($expectedResult, $this->propertySchemaCollectionRestriction->supports($constraint, $propertyMetadata));
}

public function supportsProvider(): \Generator
{
yield 'supported' => [new Collection(['fields' => []]), new PropertyMetadata(), true];

yield 'not supported' => [new Positive(), new PropertyMetadata(), false];
}

/**
* @dataProvider createProvider
*/
public function testCreate(Constraint $constraint, PropertyMetadata $propertyMetadata, array $expectedResult): void
{
self::assertSame($expectedResult, $this->propertySchemaCollectionRestriction->create($constraint, $propertyMetadata));
}

public function createProvider(): \Generator
{
yield 'empty' => [new Collection(['fields' => []]), new PropertyMetadata(), ['type' => 'object', 'properties' => [], 'additionalProperties' => false]];

yield 'with fields' => [
new Collection([
'allowExtraFields' => true,
'fields' => [
'name' => new Required([
new NotBlank(),
]),
'email' => [
new NotNull(),
new Length(['min' => 2, 'max' => 255]),
new Email(['mode' => Email::VALIDATION_MODE_LOOSE]),
],
'phone' => new Optional([
new \Symfony\Component\Validator\Constraints\Type(['type' => 'string']),
new Regex(['pattern' => '/^[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\.\/0-9]*$/']),
]),
'age' => new Optional([
new \Symfony\Component\Validator\Constraints\Type(['type' => 'int']),
]),
'social' => new Collection([
'fields' => [
'githubUsername' => new NotNull(),
],
]),
],
]),
new PropertyMetadata(),
[
'type' => 'object',
'properties' => [
'name' => [],
'email' => ['format' => 'email'],
'phone' => ['pattern' => '^(?:[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*)$'],
'age' => [],
'social' => ['type' => 'object', 'properties' => ['githubUsername' => []], 'additionalProperties' => false, 'required' => ['githubUsername']],
],
'additionalProperties' => true,
'required' => ['name', 'email', 'social'],
],
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Metadata\Property;

use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaChoiceRestriction;
use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaCollectionRestriction;
use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaCountRestriction;
use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaFormat;
use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLengthRestriction;
Expand All @@ -25,6 +26,7 @@
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Property\PropertyMetadata;
use ApiPlatform\Core\Tests\Fixtures\DummyAtLeastOneOfValidatedEntity;
use ApiPlatform\Core\Tests\Fixtures\DummyCollectionValidatedEntity;
use ApiPlatform\Core\Tests\Fixtures\DummyCompoundValidatedEntity;
use ApiPlatform\Core\Tests\Fixtures\DummyCountValidatedEntity;
use ApiPlatform\Core\Tests\Fixtures\DummyIriWithValidationEntity;
Expand Down Expand Up @@ -581,4 +583,67 @@ public function provideCountConstraintCases(): \Generator
yield 'max' => ['property' => 'dummyMax', 'expectedSchema' => ['maxItems' => 10]];
yield 'min/max' => ['property' => 'dummyMinMax', 'expectedSchema' => ['minItems' => 1, 'maxItems' => 10]];
}

public function testCreateWithPropertyCollectionRestriction(): void
{
$validatorClassMetadata = new ClassMetadata(DummyCollectionValidatedEntity::class);
(new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata);

$validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class);
$validatorMetadataFactory->getMetadataFor(DummyCollectionValidatedEntity::class)
->willReturn($validatorClassMetadata)
->shouldBeCalled();

$decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class);
$decoratedPropertyMetadataFactory->create(DummyCollectionValidatedEntity::class, 'dummyData', [])->willReturn(
new PropertyMetadata(new Type(Type::BUILTIN_TYPE_ARRAY))
)->shouldBeCalled();

$lengthRestriction = new PropertySchemaLengthRestriction();
$regexRestriction = new PropertySchemaRegexRestriction();
$formatRestriction = new PropertySchemaFormat();
$restrictionsMetadata = [
$lengthRestriction,
$regexRestriction,
$formatRestriction,
new PropertySchemaCollectionRestriction([
$lengthRestriction,
$regexRestriction,
$formatRestriction,
new PropertySchemaCollectionRestriction([
$lengthRestriction,
$regexRestriction,
$formatRestriction,
]),
]),
];

$validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory(
$validatorMetadataFactory->reveal(),
$decoratedPropertyMetadataFactory->reveal(),
$restrictionsMetadata
);

$schema = $validationPropertyMetadataFactory->create(DummyCollectionValidatedEntity::class, 'dummyData')->getSchema();

$this->assertSame([
'type' => 'object',
'properties' => [
'name' => [],
'email' => ['format' => 'email'],
'phone' => ['pattern' => '^(?:[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*)$'],
'age' => [],
'social' => [
'type' => 'object',
'properties' => [
'githubUsername' => [],
],
'additionalProperties' => false,
'required' => ['githubUsername'],
],
],
'additionalProperties' => true,
'required' => ['name', 'email', 'social'],
], $schema);
}
}
50 changes: 50 additions & 0 deletions tests/Fixtures/DummyCollectionValidatedEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Core\Tests\Fixtures;

use Symfony\Component\Validator\Constraints as Assert;

class DummyCollectionValidatedEntity
{
/**
* @var array
*
* @Assert\Collection(
* allowExtraFields=true,
* fields={
* "name"=@Assert\Required({
* @Assert\NotBlank
* }),
* "email"={
* @Assert\NotNull,
* @Assert\Length(min=2, max=255),
* @Assert\Email(mode=Assert\Email::VALIDATION_MODE_LOOSE)
* },
* "phone"=@Assert\Optional({
* @Assert\Type(type="string"),
* @Assert\Regex(pattern="/^[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\.\/0-9]*$/")
* }),
* "age"=@Assert\Optional({
* @Assert\Type(type="int")
* }),
* "social"=@Assert\Collection(
* fields={
* "githubUsername"=@Assert\NotNull
* }
* )
* }
* )
*/
public $dummyData;
}