diff --git a/api/config/services.yaml b/api/config/services.yaml index 29b5006f62..f72e699f63 100644 --- a/api/config/services.yaml +++ b/api/config/services.yaml @@ -65,11 +65,30 @@ services: - '@api_platform.filter_locator' - '@serializer.name_converter.metadata_aware' + App\Serializer\Normalizer\CircularReferenceDetectingHalItemNormalizer: + decorates: 'api_platform.hal.normalizer.item' + App\Serializer\Normalizer\CollectionItemsNormalizer: decorates: 'api_platform.hal.normalizer.collection' + App\Serializer\SerializerContextBuilder: + decorates: 'api_platform.serializer.context_builder' + + App\Serializer\PreventAutomaticEmbeddingPropertyMetadataFactory: + decorates: 'api_platform.metadata.property.metadata_factory' + # Priority should be 1 lower than the one of SerializerPropertyMetadataFactory, see + # https://github.com/api-platform/core/blob/main/src/Bridge/Symfony/Bundle/Resources/config/metadata/metadata.xml#L65 + decoration_priority: 29 + App\OpenApi\JwtDecorator: decorates: 'api_platform.openapi.factory' + ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\EagerLoadingExtension: + class: App\Doctrine\EagerLoadingExtension + api_platform.doctrine.orm.query_extension.eager_loading: + class: App\Doctrine\EagerLoadingExtension + App\Doctrine\EagerLoadingExtension: + public: false + App\Service\MailService: public: true diff --git a/api/src/Doctrine/EagerLoadingExtension.php b/api/src/Doctrine/EagerLoadingExtension.php new file mode 100644 index 0000000000..da701a8013 --- /dev/null +++ b/api/src/Doctrine/EagerLoadingExtension.php @@ -0,0 +1,328 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace App\Doctrine; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\ContextAwareQueryCollectionExtensionInterface; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryItemExtensionInterface; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\EagerLoadingTrait; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryBuilderHelper; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Core\Exception\InvalidArgumentException; +use ApiPlatform\Core\Exception\PropertyNotFoundException; +use ApiPlatform\Core\Exception\ResourceClassNotFoundException; +use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Serializer\SerializerContextBuilderInterface; +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\ClassMetadataInfo; +use Doctrine\ORM\Query\Expr\Join; +use Doctrine\ORM\Query\Expr\Select; +use Doctrine\ORM\QueryBuilder; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; + +/** + * Eager loads relations. + * + * @author Charles Sarrazin + * @author Kévin Dunglas + * @author Antoine Bluchet + * @author Baptiste Meyer + */ +final class EagerLoadingExtension implements ContextAwareQueryCollectionExtensionInterface, QueryItemExtensionInterface { + use EagerLoadingTrait; + + private $propertyNameCollectionFactory; + private $propertyMetadataFactory; + private $classMetadataFactory; + private $maxJoins; + private $serializerContextBuilder; + private $requestStack; + + /** + * @TODO move $fetchPartial after $forceEager (@soyuka) in 3.0 + */ + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, int $maxJoins = 30, bool $forceEager = true, RequestStack $requestStack = null, SerializerContextBuilderInterface $serializerContextBuilder = null, bool $fetchPartial = false, ClassMetadataFactoryInterface $classMetadataFactory = null) { + // Commented out to prevent lowering the code coverage + //if (null !== $this->requestStack) { + // @trigger_error(sprintf('Passing an instance of "%s" is deprecated since version 2.2 and will be removed in 3.0. Use the data provider\'s context instead.', RequestStack::class), \E_USER_DEPRECATED); + //} + //if (null !== $this->serializerContextBuilder) { + // @trigger_error(sprintf('Passing an instance of "%s" is deprecated since version 2.2 and will be removed in 3.0. Use the data provider\'s context instead.', SerializerContextBuilderInterface::class), \E_USER_DEPRECATED); + //} + + $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; + $this->propertyMetadataFactory = $propertyMetadataFactory; + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->classMetadataFactory = $classMetadataFactory; + $this->maxJoins = $maxJoins; + $this->forceEager = $forceEager; + $this->fetchPartial = $fetchPartial; + $this->serializerContextBuilder = $serializerContextBuilder; + $this->requestStack = $requestStack; + } + + /** + * {@inheritdoc} + */ + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass = null, string $operationName = null, array $context = []) { + $this->apply(true, $queryBuilder, $queryNameGenerator, $resourceClass, $operationName, $context); + } + + /** + * {@inheritdoc} + * + * The context may contain serialization groups which helps defining joined entities that are readable. + */ + public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, string $operationName = null, array $context = []) { + $this->apply(false, $queryBuilder, $queryNameGenerator, $resourceClass, $operationName, $context); + } + + private function apply(bool $collection, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass, ?string $operationName, array $context) { + // Commented out to prevent lowering the code coverage + //if (null === $resourceClass) { + // throw new InvalidArgumentException('The "$resourceClass" parameter must not be null'); + //} + + $options = []; + if (null !== $operationName) { + $options[($collection ? 'collection' : 'item').'_operation_name'] = $operationName; + } + + $forceEager = $this->shouldOperationForceEager($resourceClass, $options); + $fetchPartial = $this->shouldOperationFetchPartial($resourceClass, $options); + + if (!isset($context['groups']) && !isset($context['attributes'])) { + $contextType = isset($context['api_denormalize']) ? 'denormalization_context' : 'normalization_context'; + $context += $this->getNormalizationContext($context['resource_class'] ?? $resourceClass, $contextType, $options); + } + + if (empty($context[AbstractNormalizer::GROUPS]) && !isset($context[AbstractNormalizer::ATTRIBUTES])) { + return; + } + + if (!empty($context[AbstractNormalizer::GROUPS])) { + $options['serializer_groups'] = (array) $context[AbstractNormalizer::GROUPS]; + } + + $this->joinRelations($queryBuilder, $queryNameGenerator, $resourceClass, $forceEager, $fetchPartial, $queryBuilder->getRootAliases()[0], $options, $context); + } + + /** + * Joins relations to eager load. + * + * @param bool $wasLeftJoin if the relation containing the new one had a left join, we have to force the new one to left join too + * @param int $joinCount the number of joins + * @param int $currentDepth the current max depth + * + * @throws RuntimeException when the max number of joins has been reached + */ + private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, bool $forceEager, bool $fetchPartial, string $parentAlias, array $options = [], array $normalizationContext = [], bool $wasLeftJoin = false, int &$joinCount = 0, int $currentDepth = null, string $parentAssociation = null) { + if ($joinCount > $this->maxJoins) { + throw new RuntimeException('The total number of joined relations has exceeded the specified maximum. Raise the limit if necessary with the "api_platform.eager_loading.max_joins" configuration key (https://api-platform.com/docs/core/performance/#eager-loading), or limit the maximum serialization depth using the "enable_max_depth" option of the Symfony serializer (https://symfony.com/doc/current/components/serializer.html#handling-serialization-depth).'); + } + + $currentDepth = $currentDepth > 0 ? $currentDepth - 1 : $currentDepth; + $entityManager = $queryBuilder->getEntityManager(); + $classMetadata = $entityManager->getClassMetadata($resourceClass); + $attributesMetadata = $this->classMetadataFactory ? $this->classMetadataFactory->getMetadataFor($resourceClass)->getAttributesMetadata() : null; + + foreach ($classMetadata->associationMappings as $association => $mapping) { + //Don't join if max depth is enabled and the current depth limit is reached + if (0 === $currentDepth && ($normalizationContext[AbstractObjectNormalizer::ENABLE_MAX_DEPTH] ?? false)) { + continue; + } + + try { + $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $association, $options); + } catch (PropertyNotFoundException $propertyNotFoundException) { + //skip properties not found + continue; + } catch (ResourceClassNotFoundException $resourceClassNotFoundException) { + //skip associations that are not resource classes + continue; + } + + if ( + // Always skip extra lazy associations + ClassMetadataInfo::FETCH_EXTRA_LAZY === $mapping['fetch'] + // We don't want to interfere with doctrine on this association + || (false === $forceEager && ClassMetadataInfo::FETCH_EAGER !== $mapping['fetch']) + ) { + continue; + } + + // prepare the child context + $childNormalizationContext = $normalizationContext; + if (isset($normalizationContext[AbstractNormalizer::ATTRIBUTES])) { + if ($inAttributes = isset($normalizationContext[AbstractNormalizer::ATTRIBUTES][$association])) { + $childNormalizationContext[AbstractNormalizer::ATTRIBUTES] = $normalizationContext[AbstractNormalizer::ATTRIBUTES][$association]; + } + } else { + $inAttributes = null; + } + + if ( + (null === $fetchEager = $propertyMetadata->getAttribute('fetch_eager')) + && (null !== $fetchEager = $propertyMetadata->getAttribute('fetchEager')) + ) { + @trigger_error('The "fetchEager" attribute is deprecated since 2.3. Please use "fetch_eager" instead.', \E_USER_DEPRECATED); + } + + if (false === $fetchEager) { + continue; + } + + if (true !== $fetchEager && (false === $propertyMetadata->isReadable() || false === $inAttributes)) { + continue; + } + + // Avoid joining back to the parent that we just came from, but only on *ToOne relations + if ( + null !== $parentAssociation + && isset($mapping['inversedBy']) + && $mapping['inversedBy'] === $parentAssociation + && $mapping['type'] & ClassMetadata::TO_ONE + ) { + continue; + } + + $existingJoin = QueryBuilderHelper::getExistingJoin($queryBuilder, $parentAlias, $association); + + if (null !== $existingJoin) { + $associationAlias = $existingJoin->getAlias(); + $isLeftJoin = Join::LEFT_JOIN === $existingJoin->getJoinType(); + } else { + $isNullable = $mapping['joinColumns'][0]['nullable'] ?? true; + $isLeftJoin = false !== $wasLeftJoin || true === $isNullable; + $method = $isLeftJoin ? 'leftJoin' : 'innerJoin'; + + $associationAlias = $queryNameGenerator->generateJoinAlias($association); + $queryBuilder->{$method}(sprintf('%s.%s', $parentAlias, $association), $associationAlias); + ++$joinCount; + } + + if (true === $fetchPartial) { + try { + throw new RuntimeException('Partial fetching is disabled in eCamp, to prevent lowering the code coverage due to a feature we don\'t use'); + //$this->addSelect($queryBuilder, $mapping['targetEntity'], $associationAlias, $options); + } catch (ResourceClassNotFoundException $resourceClassNotFoundException) { + continue; + } + } else { + $this->addSelectOnce($queryBuilder, $associationAlias); + } + + // Avoid recursive joins for self-referencing relations + if ($mapping['targetEntity'] === $resourceClass) { + continue; + } + + // Only join the relation's relations recursively if it's a readableLink + if (true !== $fetchEager && (true !== $propertyMetadata->isReadableLink())) { + continue; + } + + if (isset($attributesMetadata[$association])) { + $maxDepth = $attributesMetadata[$association]->getMaxDepth(); + + // The current depth is the lowest max depth available in the ancestor tree. + if (null !== $maxDepth && (null === $currentDepth || $maxDepth < $currentDepth)) { + $currentDepth = $maxDepth; + } + } + + $this->joinRelations($queryBuilder, $queryNameGenerator, $mapping['targetEntity'], $forceEager, $fetchPartial, $associationAlias, $options, $childNormalizationContext, $isLeftJoin, $joinCount, $currentDepth, $association); + } + } + +// private function addSelect(QueryBuilder $queryBuilder, string $entity, string $associationAlias, array $propertyMetadataOptions) { +// $select = []; +// $entityManager = $queryBuilder->getEntityManager(); +// $targetClassMetadata = $entityManager->getClassMetadata($entity); +// if (!empty($targetClassMetadata->subClasses)) { +// $this->addSelectOnce($queryBuilder, $associationAlias); +// +// return; +// } +// +// foreach ($this->propertyNameCollectionFactory->create($entity) as $property) { +// $propertyMetadata = $this->propertyMetadataFactory->create($entity, $property, $propertyMetadataOptions); +// +// if (true === $propertyMetadata->isIdentifier()) { +// $select[] = $property; +// +// continue; +// } +// +// // If it's an embedded property see below +// if (!\array_key_exists($property, $targetClassMetadata->embeddedClasses)) { +// //the field test allows to add methods to a Resource which do not reflect real database fields +// if ($targetClassMetadata->hasField($property) && (true === $propertyMetadata->getAttribute('fetchable') || $propertyMetadata->isReadable())) { +// $select[] = $property; +// } +// +// continue; +// } +// +// // It's an embedded property, select relevant subfields +// foreach ($this->propertyNameCollectionFactory->create($targetClassMetadata->embeddedClasses[$property]['class']) as $embeddedProperty) { +// $propertyMetadata = $this->propertyMetadataFactory->create($entity, $property, $propertyMetadataOptions); +// $propertyName = "{$property}.{$embeddedProperty}"; +// if ($targetClassMetadata->hasField($propertyName) && (true === $propertyMetadata->getAttribute('fetchable') || $propertyMetadata->isReadable())) { +// $select[] = $propertyName; +// } +// } +// } +// +// $queryBuilder->addSelect(sprintf('partial %s.{%s}', $associationAlias, implode(',', $select))); +// } + + private function addSelectOnce(QueryBuilder $queryBuilder, string $alias) { + $existingSelects = array_reduce($queryBuilder->getDQLPart('select') ?? [], function ($existing, $dqlSelect) { + return ($dqlSelect instanceof Select) ? array_merge($existing, $dqlSelect->getParts()) : $existing; + }, []); + + if (!\in_array($alias, $existingSelects, true)) { + $queryBuilder->addSelect($alias); + } + } + + /** + * Gets the serializer context. + * + * @param string $contextType normalization_context or denormalization_context + * @param array $options represents the operation name so that groups are the one of the specific operation + */ + private function getNormalizationContext(string $resourceClass, string $contextType, array $options): array { + if (null !== $this->requestStack && null !== $this->serializerContextBuilder && null !== $request = $this->requestStack->getCurrentRequest()) { + return $this->serializerContextBuilder->createFromRequest($request, 'normalization_context' === $contextType); + } + + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + if (isset($options['collection_operation_name'])) { + $context = $resourceMetadata->getCollectionOperationAttribute($options['collection_operation_name'], $contextType, null, true); + } elseif (isset($options['item_operation_name'])) { + $context = $resourceMetadata->getItemOperationAttribute($options['item_operation_name'], $contextType, null, true); + } else { + $context = $resourceMetadata->getAttribute($contextType); + } + + return $context ?? []; + } +} diff --git a/api/src/Entity/Activity.php b/api/src/Entity/Activity.php index 779b6cd56b..514ab047ab 100644 --- a/api/src/Entity/Activity.php +++ b/api/src/Entity/Activity.php @@ -10,6 +10,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; /** @@ -22,10 +23,12 @@ itemOperations: [ 'get', 'patch' => [ - 'validation_groups' => ['Default', 'activity:update'], + 'validation_groups' => ['Default', 'update'], ], 'delete', ], + denormalizationContext: ['groups' => ['write']], + normalizationContext: ['groups' => ['read']], )] #[ApiFilter(SearchFilter::class, properties: ['camp'])] class Activity extends AbstractContentNodeOwner implements BelongsToCampInterface { @@ -35,6 +38,7 @@ class Activity extends AbstractContentNodeOwner implements BelongsToCampInterfac * @ORM\OneToMany(targetEntity="ScheduleEntry", mappedBy="activity", orphanRemoval=true) */ #[ApiProperty(writable: false, example: '["/schedule_entries/1a2b3c4d"]')] + #[Groups(['read'])] public Collection $scheduleEntries; /** @@ -53,6 +57,7 @@ class Activity extends AbstractContentNodeOwner implements BelongsToCampInterfac */ #[Assert\DisableAutoMapping] // camp is set in the DataPersister #[ApiProperty(writable: false, example: '/camps/1a2b3c4d')] + #[Groups(['read'])] public ?Camp $camp = null; /** @@ -63,7 +68,8 @@ class Activity extends AbstractContentNodeOwner implements BelongsToCampInterfac * @ORM\JoinColumn(nullable=false) */ #[ApiProperty(example: '/categories/1a2b3c4d')] - #[AssertBelongsToSameCamp(groups: ['activity:update'])] + #[AssertBelongsToSameCamp(groups: ['update'])] + #[Groups(['read', 'write'])] public ?Category $category = null; /** @@ -72,6 +78,7 @@ class Activity extends AbstractContentNodeOwner implements BelongsToCampInterfac * @ORM\Column(type="text") */ #[ApiProperty(example: 'Sportolympiade')] + #[Groups(['read', 'write'])] public ?string $title = null; /** @@ -80,6 +87,7 @@ class Activity extends AbstractContentNodeOwner implements BelongsToCampInterfac * @ORM\Column(type="text") */ #[ApiProperty(example: 'Spielwiese')] + #[Groups(['read', 'write'])] public string $location = ''; public function __construct() { @@ -98,11 +106,24 @@ public function setRootContentNode(?ContentNode $rootContentNode) { } #[Assert\DisableAutoMapping] + #[Groups(['read'])] public function getRootContentNode(): ?ContentNode { // Getter is here to add annotations to parent class property return $this->rootContentNode; } + /** + * Overridden in order to add annotations. + * + * {@inheritdoc} + * + * @return ContentNode[] + */ + #[Groups(['read'])] + public function getContentNodes(): array { + return parent::getContentNodes(); + } + /** * The list of people that are responsible for planning or carrying out this activity. * diff --git a/api/src/Entity/ActivityResponsible.php b/api/src/Entity/ActivityResponsible.php index 9d49abe9dd..a4238196aa 100644 --- a/api/src/Entity/ActivityResponsible.php +++ b/api/src/Entity/ActivityResponsible.php @@ -7,6 +7,7 @@ use App\Validator\AssertBelongsToSameCamp; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; /** @@ -20,6 +21,8 @@ #[ApiResource( collectionOperations: ['get', 'post'], itemOperations: ['get', 'delete'], + denormalizationContext: ['groups' => ['write']], + normalizationContext: ['groups' => ['read']], )] #[UniqueEntity(fields: ['activity', 'campCollaboration'])] class ActivityResponsible extends BaseEntity implements BelongsToCampInterface { @@ -31,6 +34,7 @@ class ActivityResponsible extends BaseEntity implements BelongsToCampInterface { */ #[Assert\NotNull] #[ApiProperty(example: '/activities/1a2b3c4d')] + #[Groups(['read', 'write'])] public ?Activity $activity = null; /** @@ -41,6 +45,7 @@ class ActivityResponsible extends BaseEntity implements BelongsToCampInterface { */ #[ApiProperty(example: '/camp_collaborations/1a2b3c4d')] #[AssertBelongsToSameCamp] + #[Groups(['read', 'write'])] public ?CampCollaboration $campCollaboration = null; #[ApiProperty(readable: false)] diff --git a/api/src/Entity/BaseEntity.php b/api/src/Entity/BaseEntity.php index c811ac8c4a..a77a5bc36b 100644 --- a/api/src/Entity/BaseEntity.php +++ b/api/src/Entity/BaseEntity.php @@ -7,6 +7,7 @@ use DateTime; use Doctrine\ORM\Mapping as ORM; use Gedmo\Mapping\Annotation as Gedmo; +use Symfony\Component\Serializer\Annotation\Groups; /** * @ORM\MappedSuperclass @@ -25,6 +26,7 @@ abstract class BaseEntity { * @ORM\CustomIdGenerator(class=IdGenerator::class) */ #[ApiProperty(writable: false, example: '1a2b3c4d')] + #[Groups(['read'])] protected string $id; /** diff --git a/api/src/Entity/Camp.php b/api/src/Entity/Camp.php index ca30302c84..1d3ee19140 100644 --- a/api/src/Entity/Camp.php +++ b/api/src/Entity/Camp.php @@ -25,26 +25,37 @@ 'post' => [ 'security' => 'is_fully_authenticated()', 'input_formats' => ['jsonld', 'jsonapi', 'json'], - 'validation_groups' => ['Default', 'camp:create'], + 'validation_groups' => ['Default', 'create'], + 'denormalization_context' => ['groups' => ['write', 'create']], + 'normalization_context' => self::ITEM_NORMALIZATION_CONTEXT, ], ], itemOperations: [ - 'get' => ['security' => 'object.owner == user or is_granted("ROLE_ADMIN")'], + 'get' => [ + 'security' => 'object.owner == user or is_granted("ROLE_ADMIN")', + 'normalization_context' => self::ITEM_NORMALIZATION_CONTEXT, + ], 'patch' => [ 'security' => 'object.owner == user or is_granted("ROLE_ADMIN")', - 'denormalization_context' => [ - 'groups' => ['camp:update'], - 'allow_extra_attributes' => false, - ], + 'denormalization_context' => ['groups' => ['write', 'update']], + 'normalization_context' => self::ITEM_NORMALIZATION_CONTEXT, ], 'delete' => ['security' => 'object.owner == user or is_granted("ROLE_ADMIN")'], - ] + ], + denormalizationContext: ['groups' => ['write']], + normalizationContext: ['groups' => ['read']], )] class Camp extends BaseEntity implements BelongsToCampInterface { + public const ITEM_NORMALIZATION_CONTEXT = [ + 'groups' => ['read', 'Camp:Periods', 'Period:Days'], + 'swagger_definition_name' => 'read', + ]; + /** * @ORM\OneToMany(targetEntity="CampCollaboration", mappedBy="camp", orphanRemoval=true) */ #[SerializedName('campCollaborations')] + #[Groups(['read'])] public Collection $collaborations; /** @@ -56,9 +67,12 @@ class Camp extends BaseEntity implements BelongsToCampInterface { * @ORM\OrderBy({"start": "ASC"}) */ #[Assert\Valid] - #[Assert\Count(min: 1, groups: ['camp:create'])] - #[ApiProperty(writableLink: true, example: '[{ "description": "Hauptlager", "start": "2022-01-01", "end": "2022-01-08" }]')] - #[Groups(['Default'])] + #[Assert\Count(min: 1, groups: ['create'])] + #[ApiProperty( + writableLink: true, + example: '[{ "description": "Hauptlager", "start": "2022-01-01", "end": "2022-01-08" }]', + )] + #[Groups(['read', 'create'])] public Collection $periods; /** @@ -67,6 +81,7 @@ class Camp extends BaseEntity implements BelongsToCampInterface { * @ORM\OneToMany(targetEntity="Category", mappedBy="camp", orphanRemoval=true) */ #[ApiProperty(writable: false, example: '["/categories/1a2b3c4d"]')] + #[Groups(['read'])] public Collection $categories; /** @@ -76,6 +91,7 @@ class Camp extends BaseEntity implements BelongsToCampInterface { * @ORM\OneToMany(targetEntity="Activity", mappedBy="camp", orphanRemoval=true) */ #[ApiProperty(writable: false, example: '["/activities/1a2b3c4d"]')] + #[Groups(['read'])] public Collection $activities; /** @@ -85,6 +101,7 @@ class Camp extends BaseEntity implements BelongsToCampInterface { * @ORM\OneToMany(targetEntity="MaterialList", mappedBy="camp", orphanRemoval=true) */ #[ApiProperty(writable: false, example: '["/material_lists/1a2b3c4d"]')] + #[Groups(['read'])] public Collection $materialLists; /** @@ -104,7 +121,8 @@ class Camp extends BaseEntity implements BelongsToCampInterface { */ #[Assert\Type('bool')] #[Assert\DisableAutoMapping] - #[ApiProperty(example: false, writable: false)] + #[ApiProperty(example: true, writable: false)] + #[Groups(['read'])] public bool $isPrototype = false; /** @@ -116,7 +134,7 @@ class Camp extends BaseEntity implements BelongsToCampInterface { #[InputFilter\CleanHTML] #[Assert\NotBlank] #[ApiProperty(example: 'SoLa 2022')] - #[Groups(['Default', 'camp:update'])] + #[Groups(['read', 'write'])] public string $name; /** @@ -129,7 +147,7 @@ class Camp extends BaseEntity implements BelongsToCampInterface { #[Assert\NotBlank] #[Assert\Length(max: 32)] #[ApiProperty(example: 'Abteilungs-Sommerlager 2022')] - #[Groups(['Default', 'camp:update'])] + #[Groups(['read', 'write'])] public string $title; /** @@ -141,7 +159,7 @@ class Camp extends BaseEntity implements BelongsToCampInterface { #[InputFilter\CleanHTML] #[Assert\Length(max: 128)] #[ApiProperty(example: 'Piraten')] - #[Groups(['Default', 'camp:update'])] + #[Groups(['read', 'write'])] public ?string $motto = null; /** @@ -153,7 +171,7 @@ class Camp extends BaseEntity implements BelongsToCampInterface { #[InputFilter\CleanHTML] #[Assert\Length(max: 128)] #[ApiProperty(example: 'Wiese hinter der alten Mühle')] - #[Groups(['Default', 'camp:update'])] + #[Groups(['read', 'write'])] public ?string $addressName = null; /** @@ -165,7 +183,7 @@ class Camp extends BaseEntity implements BelongsToCampInterface { #[InputFilter\CleanHTML] #[Assert\Length(max: 128)] #[ApiProperty(example: 'Schönriedweg 23')] - #[Groups(['Default', 'camp:update'])] + #[Groups(['read', 'write'])] public ?string $addressStreet = null; /** @@ -177,7 +195,7 @@ class Camp extends BaseEntity implements BelongsToCampInterface { #[InputFilter\CleanHTML] #[Assert\Length(max: 128)] #[ApiProperty(example: '1234')] - #[Groups(['Default', 'camp:update'])] + #[Groups(['read', 'write'])] public ?string $addressZipcode = null; /** @@ -189,7 +207,7 @@ class Camp extends BaseEntity implements BelongsToCampInterface { #[InputFilter\CleanHTML] #[Assert\Length(max: 128)] #[ApiProperty(example: 'Hintertüpfingen')] - #[Groups(['Default', 'camp:update'])] + #[Groups(['read', 'write'])] public ?string $addressCity = null; /** @@ -201,6 +219,7 @@ class Camp extends BaseEntity implements BelongsToCampInterface { */ #[Assert\DisableAutoMapping] #[ApiProperty(writable: false)] + #[Groups(['read'])] public ?User $creator = null; /** @@ -222,6 +241,16 @@ public function __construct() { $this->materialLists = new ArrayCollection(); } + /** + * @return Period[] + */ + #[ApiProperty(readableLink: true)] + #[SerializedName('periods')] + #[Groups(['Camp:Periods'])] + public function getEmbeddedPeriods(): array { + return $this->periods->getValues(); + } + #[ApiProperty(readable: false)] public function getCamp(): ?Camp { return $this; diff --git a/api/src/Entity/CampCollaboration.php b/api/src/Entity/CampCollaboration.php index 53dc89cf1e..7a2566f4c4 100644 --- a/api/src/Entity/CampCollaboration.php +++ b/api/src/Entity/CampCollaboration.php @@ -26,8 +26,7 @@ 'get', 'post' => [ 'denormalization_context' => [ - 'groups' => ['campCollaboration:create'], - 'allow_extra_attributes' => false, + 'groups' => ['write', 'create'], ], 'openapi_context' => [ 'description' => 'Also sends an invitation email to the inviteEmail address, if specified.', @@ -36,13 +35,11 @@ ], itemOperations: [ 'get', - 'patch' => ['denormalization_context' => [ - 'groups' => ['campCollaboration:update'], - 'allow_extra_attributes' => false, - ]], + 'patch' => ['denormalization_context' => ['groups' => ['write', 'update']]], 'delete', ], - normalizationContext: ['skip_null_values' => false], + denormalizationContext: ['groups' => ['write']], + normalizationContext: ['groups' => ['read']], )] #[ApiFilter(SearchFilter::class, properties: ['camp'])] class CampCollaboration extends BaseEntity implements BelongsToCampInterface { @@ -90,7 +87,7 @@ class CampCollaboration extends BaseEntity implements BelongsToCampInterface { */ #[AssertEitherIsNull(other: 'user')] #[ApiProperty(example: 'some-email@example.com')] - #[Groups(['Default', 'campCollaboration:create'])] + #[Groups(['read', 'create'])] public ?string $inviteEmail = null; /** @@ -108,7 +105,7 @@ class CampCollaboration extends BaseEntity implements BelongsToCampInterface { */ #[AssertEitherIsNull(other: 'inviteEmail')] #[ApiProperty(example: '/users/1a2b3c4d')] - #[Groups(['Default', 'campCollaboration:create'])] + #[Groups(['read', 'create'])] public ?User $user = null; /** @@ -118,7 +115,7 @@ class CampCollaboration extends BaseEntity implements BelongsToCampInterface { * @ORM\JoinColumn(nullable=false, onDelete="cascade") */ #[ApiProperty(example: '/camps/1a2b3c4d')] - #[Groups(['Default', 'campCollaboration:create'])] + #[Groups(['read', 'create'])] public ?Camp $camp = null; /** @@ -130,7 +127,7 @@ class CampCollaboration extends BaseEntity implements BelongsToCampInterface { */ #[Assert\Choice(choices: self::VALID_STATUS)] #[ApiProperty(default: self::STATUS_INVITED, example: self::STATUS_INACTIVE)] - #[Groups(['Default', 'campCollaboration:update'])] + #[Groups(['read', 'update'])] public string $status = self::STATUS_INVITED; /** @@ -141,7 +138,7 @@ class CampCollaboration extends BaseEntity implements BelongsToCampInterface { */ #[Assert\Choice(choices: self::VALID_ROLES)] #[ApiProperty(example: self::ROLE_MEMBER)] - #[Groups(['Default', 'campCollaboration:create', 'campCollaboration:update'])] + #[Groups(['read', 'write'])] public string $role; /** diff --git a/api/src/Entity/Category.php b/api/src/Entity/Category.php index 422da4a670..6a85de82bc 100644 --- a/api/src/Entity/Category.php +++ b/api/src/Entity/Category.php @@ -21,15 +21,17 @@ * @ORM\Entity */ #[ApiResource( - collectionOperations: ['get', 'post'], + collectionOperations: [ + 'get', + 'post' => ['denormalization_context' => ['groups' => ['write', 'create']]], + ], itemOperations: [ 'get', - 'patch' => ['denormalization_context' => [ - 'groups' => ['category:update'], - 'allow_extra_attributes' => false, - ]], + 'patch' => ['denormalization_context' => ['groups' => ['write', 'update']]], 'delete', - ] + ], + denormalizationContext: ['groups' => ['write']], + normalizationContext: ['groups' => ['read']], )] #[ApiFilter(SearchFilter::class, properties: ['camp'])] class Category extends AbstractContentNodeOwner implements BelongsToCampInterface { @@ -40,7 +42,7 @@ class Category extends AbstractContentNodeOwner implements BelongsToCampInterfac * @ORM\JoinColumn(nullable=false, onDelete="cascade") */ #[ApiProperty(example: '/camps/1a2b3c4d')] - #[Groups(['Default'])] + #[Groups(['read', 'create'])] public ?Camp $camp = null; /** @@ -53,7 +55,7 @@ class Category extends AbstractContentNodeOwner implements BelongsToCampInterfac * ) */ #[ApiProperty(example: '["/content_types/1a2b3c4d"]')] - #[Groups(['Default', 'category:update'])] + #[Groups(['read', 'write'])] public Collection $preferredContentTypes; /** @@ -80,7 +82,7 @@ class Category extends AbstractContentNodeOwner implements BelongsToCampInterfac * @ORM\Column(type="text", nullable=false) */ #[ApiProperty(example: 'LS')] - #[Groups(['Default', 'category:update'])] + #[Groups(['read', 'write'])] public ?string $short = null; /** @@ -89,7 +91,7 @@ class Category extends AbstractContentNodeOwner implements BelongsToCampInterfac * @ORM\Column(type="text", nullable=false) */ #[ApiProperty(example: 'Lagersport')] - #[Groups(['Default', 'category:update'])] + #[Groups(['read', 'write'])] public ?string $name = null; /** @@ -99,7 +101,7 @@ class Category extends AbstractContentNodeOwner implements BelongsToCampInterfac */ #[Assert\Regex(pattern: '/^#[0-9a-zA-Z]{6}$/')] #[ApiProperty(example: '#4CAF50')] - #[Groups(['Default', 'category:update'])] + #[Groups(['read', 'write'])] public ?string $color = null; /** @@ -109,8 +111,8 @@ class Category extends AbstractContentNodeOwner implements BelongsToCampInterfac * @ORM\Column(type="string", length=1, nullable=false) */ #[Assert\Choice(choices: ['a', 'A', 'i', 'I', '1'])] - #[ApiProperty(default: '1', example: 'a')] - #[Groups(['Default', 'category:update'])] + #[ApiProperty(default: '1', example: '1')] + #[Groups(['read', 'write'])] public string $numberingStyle = '1'; public function __construct() { @@ -159,11 +161,24 @@ public function setRootContentNode(?ContentNode $rootContentNode) { } #[Assert\DisableAutoMapping] + #[Groups(['read'])] public function getRootContentNode(): ?ContentNode { // Getter is here to add annotations to parent class property return $this->rootContentNode; } + /** + * Overridden in order to add annotations. + * + * {@inheritdoc} + * + * @return ContentNode[] + */ + #[Groups(['read'])] + public function getContentNodes(): array { + return parent::getContentNodes(); + } + public function getStyledNumber(int $num): string { switch ($this->numberingStyle) { case 'a': diff --git a/api/src/Entity/ContentNode.php b/api/src/Entity/ContentNode.php index 92b0ffc645..510ee87d47 100644 --- a/api/src/Entity/ContentNode.php +++ b/api/src/Entity/ContentNode.php @@ -26,18 +26,20 @@ * @ORM\Entity */ #[ApiResource( - collectionOperations: ['get', 'post'], + collectionOperations: [ + 'get', + 'post' => ['denormalization_context' => ['groups' => ['write', 'create']]], + ], itemOperations: [ 'get', 'patch' => [ - 'denormalization_context' => [ - 'groups' => ['contentNode:update'], - 'allow_extra_attributes' => false, - ], - 'validation_groups' => ['Default', 'contentNode:update'], + 'denormalization_context' => ['groups' => ['write', 'update']], + 'validation_groups' => ['Default', 'update'], ], 'delete' => ['security' => 'object.owner === null'], - ] + ], + denormalizationContext: ['groups' => ['write']], + normalizationContext: ['groups' => ['read']], )] #[ApiFilter(SearchFilter::class, properties: ['parent'])] class ContentNode extends BaseEntity implements BelongsToCampInterface { @@ -57,6 +59,7 @@ class ContentNode extends BaseEntity implements BelongsToCampInterface { * @ORM\JoinColumn(nullable=true) */ #[ApiProperty(writable: false, example: '/content_nodes/1a2b3c4d')] + #[Groups(['read'])] public ContentNode $root; /** @@ -80,10 +83,10 @@ class ContentNode extends BaseEntity implements BelongsToCampInterface { messageBothNull: 'Must not be null on non-root content nodes.', messageNoneNull: 'Must be null on root content nodes.' )] - #[AssertBelongsToSameOwner(groups: ['contentNode:update'])] - #[AssertNoLoop(groups: ['contentNode:update'])] + #[AssertBelongsToSameOwner(groups: ['update'])] + #[AssertNoLoop(groups: ['update'])] #[ApiProperty(example: '/content_nodes/1a2b3c4d')] - #[Groups(['Default', 'contentNode:update'])] + #[Groups(['read', 'write'])] public ?ContentNode $parent = null; /** @@ -92,6 +95,7 @@ class ContentNode extends BaseEntity implements BelongsToCampInterface { * @ORM\OneToMany(targetEntity="ContentNode", mappedBy="parent", cascade={"remove"}) */ #[ApiProperty(writable: false, example: '["/content_nodes/1a2b3c4d"]')] + #[Groups(['read'])] public Collection $children; /** @@ -101,7 +105,7 @@ class ContentNode extends BaseEntity implements BelongsToCampInterface { * @ORM\Column(type="text", nullable=true) */ #[ApiProperty(example: 'footer')] - #[Groups(['Default', 'contentNode:update'])] + #[Groups(['read', 'write'])] public ?string $slot = null; /** @@ -111,7 +115,7 @@ class ContentNode extends BaseEntity implements BelongsToCampInterface { * @ORM\Column(type="integer", nullable=true) */ #[ApiProperty(example: '0')] - #[Groups(['Default', 'contentNode:update'])] + #[Groups(['read', 'write'])] public ?int $position = null; /** @@ -122,7 +126,7 @@ class ContentNode extends BaseEntity implements BelongsToCampInterface { * @ORM\Column(type="json", nullable=true) */ #[ApiProperty(example: '{}')] - #[Groups(['Default', 'contentNode:update'])] + #[Groups(['read', 'write'])] public ?array $jsonConfig = null; /** @@ -132,7 +136,7 @@ class ContentNode extends BaseEntity implements BelongsToCampInterface { * @ORM\Column(type="text", nullable=true) */ #[ApiProperty(example: 'Schlechtwetterprogramm')] - #[Groups(['Default', 'contentNode:update'])] + #[Groups(['read', 'write'])] public ?string $instanceName = null; /** @@ -144,7 +148,7 @@ class ContentNode extends BaseEntity implements BelongsToCampInterface { * @ORM\JoinColumn(nullable=false) */ #[ApiProperty(example: '/content_types/1a2b3c4d')] - #[Groups(['Default'])] + #[Groups(['read', 'create'])] public ?ContentType $contentType = null; /** @@ -165,6 +169,7 @@ public function __construct() { * The name of the content type of this content node. Read-only, for convenience. */ #[ApiProperty(example: 'SafetyConcept')] + #[Groups(['read'])] public function getContentTypeName(): string { return $this->contentType?->name; } @@ -174,6 +179,7 @@ public function getContentTypeName(): string { */ #[SerializedName('owner')] #[ApiProperty(writable: false, example: '/activities/1a2b3c4d')] + #[Groups(['read'])] public function getRootOwner(): Activity | Category | AbstractContentNodeOwner { return $this->root->owner; } @@ -185,6 +191,7 @@ public function getRootOwner(): Activity | Category | AbstractContentNodeOwner { * @throws Exception when the owner is neither an activity nor a category */ #[ApiProperty(example: '/categories/1a2b3c4d')] + #[Groups(['read'])] public function getOwnerCategory(): Category { $owner = $this->getRootOwner(); diff --git a/api/src/Entity/ContentType.php b/api/src/Entity/ContentType.php index ef7c242b35..bf64e2e80a 100644 --- a/api/src/Entity/ContentType.php +++ b/api/src/Entity/ContentType.php @@ -5,6 +5,7 @@ use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; /** * Defines a type of content that can be present in a content node tree. A content type @@ -16,6 +17,7 @@ #[ApiResource( collectionOperations: ['get'], itemOperations: ['get'], + normalizationContext: ['groups' => ['read']], )] class ContentType extends BaseEntity { /** @@ -25,6 +27,7 @@ class ContentType extends BaseEntity { * @ORM\Column(type="string", length=32, unique=true) */ #[ApiProperty(writable: false, example: 'SafetyConcept')] + #[Groups(['read'])] public ?string $name = null; /** @@ -33,6 +36,7 @@ class ContentType extends BaseEntity { * @ORM\Column(type="boolean") */ #[ApiProperty(writable: false, example: 'true')] + #[Groups(['read'])] public bool $active = true; /** diff --git a/api/src/Entity/Day.php b/api/src/Entity/Day.php index e8e8e1272c..ad68f99be3 100644 --- a/api/src/Entity/Day.php +++ b/api/src/Entity/Day.php @@ -10,6 +10,7 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Serializer\Annotation\SerializedName; /** @@ -26,6 +27,8 @@ #[ApiResource( collectionOperations: ['get'], itemOperations: ['get'], + denormalizationContext: ['groups' => ['write']], + normalizationContext: ['groups' => ['read']], )] #[ApiFilter(SearchFilter::class, properties: ['period'])] #[UniqueEntity(fields: ['period', 'dayOffset'])] @@ -36,6 +39,7 @@ class Day extends BaseEntity implements BelongsToCampInterface { * @ORM\OneToMany(targetEntity="DayResponsible", mappedBy="day", orphanRemoval=true) */ #[ApiProperty(writable: false, example: '["/day_responsibles/1a2b3c4d"]')] + #[Groups(['read'])] public Collection $dayResponsibles; /** @@ -44,7 +48,8 @@ class Day extends BaseEntity implements BelongsToCampInterface { * @ORM\ManyToOne(targetEntity="Period", inversedBy="days") * @ORM\JoinColumn(nullable=false, onDelete="cascade") */ - #[ApiProperty(writable: false, example: '/periods/1a2b3c4d')] + #[ApiProperty(example: '/periods/1a2b3c4d')] + #[Groups(['read'])] public ?Period $period = null; /** @@ -53,6 +58,7 @@ class Day extends BaseEntity implements BelongsToCampInterface { * @ORM\Column(type="integer") */ #[ApiProperty(writable: false, example: '1')] + #[Groups(['read'])] public int $dayOffset = 0; public function __construct() { @@ -69,6 +75,7 @@ public function getCamp(): ?Camp { */ #[ApiProperty(example: '2')] #[SerializedName('number')] + #[Groups(['read'])] public function getDayNumber(): int { return $this->dayOffset + 1; } diff --git a/api/src/Entity/DayResponsible.php b/api/src/Entity/DayResponsible.php index 4e770c96d2..8060fa4e03 100644 --- a/api/src/Entity/DayResponsible.php +++ b/api/src/Entity/DayResponsible.php @@ -27,6 +27,7 @@ use App\Validator\AssertBelongsToSameCamp; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; /** @@ -40,6 +41,8 @@ #[ApiResource( collectionOperations: ['get', 'post'], itemOperations: ['get', 'delete'], + denormalizationContext: ['groups' => ['write']], + normalizationContext: ['groups' => ['read']], )] #[ApiFilter(SearchFilter::class, properties: ['day'])] #[UniqueEntity(fields: ['day', 'campCollaboration'])] @@ -52,6 +55,7 @@ class DayResponsible extends BaseEntity implements BelongsToCampInterface { */ #[Assert\NotNull] #[ApiProperty(example: '/days/1a2b3c4d')] + #[Groups(['read', 'write'])] public ?Day $day = null; /** @@ -62,6 +66,7 @@ class DayResponsible extends BaseEntity implements BelongsToCampInterface { */ #[AssertBelongsToSameCamp] #[ApiProperty(example: '/camp_collaborations/1a2b3c4d')] + #[Groups(['read', 'write'])] public ?CampCollaboration $campCollaboration = null; #[ApiProperty(readable: false)] diff --git a/api/src/Entity/MaterialItem.php b/api/src/Entity/MaterialItem.php index c1397d2c95..fdc167e987 100644 --- a/api/src/Entity/MaterialItem.php +++ b/api/src/Entity/MaterialItem.php @@ -10,6 +10,7 @@ use App\Validator\AssertEitherIsNull; use App\Validator\MaterialItemUpdateGroupSequence; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; /** * A physical item that is needed for carrying out a programme or camp. @@ -22,7 +23,9 @@ 'get', 'patch' => ['validation_groups' => MaterialItemUpdateGroupSequence::class], 'delete', - ] + ], + denormalizationContext: ['groups' => ['write']], + normalizationContext: ['groups' => ['read']], )] #[ApiFilter(SearchFilter::class, properties: ['materialList', 'period'])] class MaterialItem extends BaseEntity implements BelongsToCampInterface { @@ -33,8 +36,9 @@ class MaterialItem extends BaseEntity implements BelongsToCampInterface { * @ORM\ManyToOne(targetEntity="MaterialList", inversedBy="materialItems") * @ORM\JoinColumn(nullable=false, onDelete="cascade") */ - #[AssertBelongsToSameCamp(compareToPrevious: true, groups: ['materialItem:update'])] + #[AssertBelongsToSameCamp(compareToPrevious: true, groups: ['update'])] #[ApiProperty(example: '/material_lists/1a2b3c4d')] + #[Groups(['read', 'write'])] public ?MaterialList $materialList = null; /** @@ -46,6 +50,7 @@ class MaterialItem extends BaseEntity implements BelongsToCampInterface { #[AssertBelongsToSameCamp] #[AssertEitherIsNull(other: 'contentNode')] #[ApiProperty(example: '/periods/1a2b3c4d')] + #[Groups(['read', 'write'])] public ?Period $period = null; /** @@ -57,6 +62,7 @@ class MaterialItem extends BaseEntity implements BelongsToCampInterface { #[AssertBelongsToSameCamp] #[AssertEitherisNull(other: 'period')] #[ApiProperty(example: '/content_nodes/1a2b3c4d')] + #[Groups(['read', 'write'])] public ?ContentNode $contentNode = null; /** @@ -65,6 +71,7 @@ class MaterialItem extends BaseEntity implements BelongsToCampInterface { * @ORM\Column(type="text", nullable=false) */ #[ApiProperty(example: 'Volleyball')] + #[Groups(['read', 'write'])] public ?string $article = null; /** @@ -73,6 +80,7 @@ class MaterialItem extends BaseEntity implements BelongsToCampInterface { * @ORM\Column(type="float", nullable=true) */ #[ApiProperty(example: 1.5)] + #[Groups(['read', 'write'])] public ?float $quantity = null; /** @@ -81,6 +89,7 @@ class MaterialItem extends BaseEntity implements BelongsToCampInterface { * @ORM\Column(type="text", nullable=true) */ #[ApiProperty(example: 'kg')] + #[Groups(['read', 'write'])] public ?string $unit = null; #[ApiProperty(readable: false)] diff --git a/api/src/Entity/MaterialList.php b/api/src/Entity/MaterialList.php index f14991f423..a559bc403d 100644 --- a/api/src/Entity/MaterialList.php +++ b/api/src/Entity/MaterialList.php @@ -18,15 +18,13 @@ * @ORM\Entity */ #[ApiResource( - collectionOperations: ['get', 'post'], - itemOperations: [ + collectionOperations: [ 'get', - 'patch' => ['denormalization_context' => [ - 'groups' => ['materialList:update'], - 'allow_extra_attributes' => false, - ]], - 'delete', - ] + 'post' => ['denormalization_context' => ['groups' => ['write', 'create']]], + ], + itemOperations: ['get', 'patch', 'delete'], + denormalizationContext: ['groups' => ['write']], + normalizationContext: ['groups' => ['read']], )] #[ApiFilter(SearchFilter::class, properties: ['camp'])] class MaterialList extends BaseEntity implements BelongsToCampInterface { @@ -36,6 +34,7 @@ class MaterialList extends BaseEntity implements BelongsToCampInterface { * @ORM\OneToMany(targetEntity="MaterialItem", mappedBy="materialList") */ #[ApiProperty(writable: false, example: '["/material_items/1a2b3c4d"]')] + #[Groups(['read'])] public Collection $materialItems; /** @@ -45,7 +44,7 @@ class MaterialList extends BaseEntity implements BelongsToCampInterface { * @ORM\JoinColumn(nullable=false, onDelete="cascade") */ #[ApiProperty(example: '/camps/1a2b3c4d')] - #[Groups(['Default'])] + #[Groups(['read', 'create'])] public ?Camp $camp = null; /** @@ -63,7 +62,7 @@ class MaterialList extends BaseEntity implements BelongsToCampInterface { * @ORM\Column(type="text", nullable=false) */ #[ApiProperty(example: 'Lebensmittel')] - #[Groups(['Default', 'materialList:update'])] + #[Groups(['read', 'write'])] public ?string $name = null; public function __construct() { diff --git a/api/src/Entity/Period.php b/api/src/Entity/Period.php index af38be88fd..7acd619fc8 100644 --- a/api/src/Entity/Period.php +++ b/api/src/Entity/Period.php @@ -11,6 +11,7 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Annotation\SerializedName; use Symfony\Component\Validator\Constraints as Assert; /** @@ -20,25 +21,33 @@ * @ORM\Entity */ #[ApiResource( - collectionOperations: ['get', 'post'], - itemOperations: [ + collectionOperations: [ 'get', - 'patch' => ['denormalization_context' => [ - 'groups' => ['period:update'], - 'allow_extra_attributes' => false, - ]], + 'post' => ['denormalization_context' => ['groups' => ['write', 'create']]], + ], + itemOperations: [ + 'get' => ['normalization_context' => self::ITEM_NORMALIZATION_CONTEXT], + 'patch', 'delete', - ] + ], + denormalizationContext: ['groups' => ['write']], + normalizationContext: ['groups' => ['read']], )] #[ApiFilter(SearchFilter::class, properties: ['camp'])] class Period extends BaseEntity implements BelongsToCampInterface { + public const ITEM_NORMALIZATION_CONTEXT = [ + 'groups' => ['read', 'Period:Camp', 'Period:Days'], + 'swagger_definition_name' => 'read', + ]; + /** * The days in this time period. These are generated automatically. * * @ORM\OneToMany(targetEntity="Day", mappedBy="period", orphanRemoval=true) * @ORM\OrderBy({"dayOffset": "ASC"}) */ - #[ApiProperty(writable: false, example: '["/days/1a2b3c4d"]')] + #[ApiProperty(writable: false, example: '["/days?period=/periods/1a2b3c4d"]')] + #[Groups(['read'])] public Collection $days; /** @@ -49,6 +58,7 @@ class Period extends BaseEntity implements BelongsToCampInterface { * @ORM\OrderBy({"periodOffset": "ASC"}) */ #[ApiProperty(writable: false, example: '["/schedule_entries/1a2b3c4d"]')] + #[Groups(['read'])] public Collection $scheduleEntries; /** @@ -58,6 +68,7 @@ class Period extends BaseEntity implements BelongsToCampInterface { * @ORM\OneToMany(targetEntity="MaterialItem", mappedBy="period") */ #[ApiProperty(writable: false, example: '["/material_items/1a2b3c4d"]')] + #[Groups(['read'])] public Collection $materialItems; /** @@ -67,7 +78,7 @@ class Period extends BaseEntity implements BelongsToCampInterface { * @ORM\JoinColumn(nullable=false) */ #[ApiProperty(example: '/camps/1a2b3c4d')] - #[Groups(['Default'])] + #[Groups(['read', 'create'])] public ?Camp $camp = null; /** @@ -79,7 +90,7 @@ class Period extends BaseEntity implements BelongsToCampInterface { */ #[Assert\NotBlank] #[ApiProperty(example: 'Hauptlager')] - #[Groups(['Default', 'period:update'])] + #[Groups(['read', 'write'])] public ?string $description = null; /** @@ -95,7 +106,7 @@ class Period extends BaseEntity implements BelongsToCampInterface { */ #[Assert\LessThanOrEqual(propertyPath: 'end')] #[ApiProperty(example: '2022-01-01', openapiContext: ['format' => 'date'])] - #[Groups(['Default', 'period:update'])] + #[Groups(['read', 'write'])] public ?DateTimeInterface $start = null; /** @@ -106,7 +117,7 @@ class Period extends BaseEntity implements BelongsToCampInterface { */ #[Assert\GreaterThanOrEqual(propertyPath: 'start')] #[ApiProperty(example: '2022-01-08', openapiContext: ['format' => 'date'])] - #[Groups(['Default', 'period:update'])] + #[Groups(['read', 'write'])] public ?DateTimeInterface $end = null; public function __construct() { @@ -115,6 +126,23 @@ public function __construct() { $this->materialItems = new ArrayCollection(); } + /** + * @return Day[] + */ + #[ApiProperty(readableLink: true)] + #[SerializedName('days')] + #[Groups('Period:Days')] + public function getEmbeddedDays(): array { + return $this->days->getValues(); + } + + #[ApiProperty(readableLink: true)] + #[SerializedName('camp')] + #[Groups(['Period:Camp'])] + public function getEmbeddedCamp(): ?Camp { + return $this->camp; + } + public function getCamp(): ?Camp { return $this->camp; } diff --git a/api/src/Entity/ScheduleEntry.php b/api/src/Entity/ScheduleEntry.php index a274efae9d..cdbae6569c 100644 --- a/api/src/Entity/ScheduleEntry.php +++ b/api/src/Entity/ScheduleEntry.php @@ -20,15 +20,13 @@ * @ORM\Entity */ #[ApiResource( - collectionOperations: ['get', 'post'], - itemOperations: [ + collectionOperations: [ 'get', - 'patch' => ['denormalization_context' => [ - 'groups' => ['scheduleEntry:update'], - 'allow_extra_attributes' => false, - ]], - 'delete', - ] + 'post' => ['denormalization_context' => ['groups' => ['write', 'create']]], + ], + itemOperations: ['get', 'patch', 'delete'], + denormalizationContext: ['groups' => ['write']], + normalizationContext: ['groups' => ['read']], )] #[ApiFilter(SearchFilter::class, properties: ['period', 'activity'])] class ScheduleEntry extends BaseEntity implements BelongsToCampInterface { @@ -40,7 +38,7 @@ class ScheduleEntry extends BaseEntity implements BelongsToCampInterface { */ #[AssertBelongsToSameCamp] #[ApiProperty(example: '/periods/1a2b3c4d')] - #[Groups(['Default', 'scheduleEntry:update'])] + #[Groups(['read', 'write'])] public ?Period $period = null; /** @@ -52,7 +50,7 @@ class ScheduleEntry extends BaseEntity implements BelongsToCampInterface { * @ORM\JoinColumn(nullable=false, onDelete="cascade") */ #[ApiProperty(example: '/activities/1a2b3c4d')] - #[Groups(['Default'])] + #[Groups(['read', 'create'])] public ?Activity $activity = null; /** @@ -64,7 +62,7 @@ class ScheduleEntry extends BaseEntity implements BelongsToCampInterface { */ #[Assert\GreaterThanOrEqual(value: 0)] #[ApiProperty(example: 1440)] - #[Groups(['Default', 'scheduleEntry:update'])] + #[Groups(['read', 'write'])] public int $periodOffset = 0; /** @@ -74,7 +72,7 @@ class ScheduleEntry extends BaseEntity implements BelongsToCampInterface { */ #[Assert\GreaterThan(value: 0)] #[ApiProperty(example: 90)] - #[Groups(['Default', 'scheduleEntry:update'])] + #[Groups(['read', 'write'])] public int $length = 0; /** @@ -88,7 +86,7 @@ class ScheduleEntry extends BaseEntity implements BelongsToCampInterface { * --> left is a MariaDB keyword, therefore escaping for column name necessary */ #[ApiProperty(default: 0, example: 0.6)] - #[Groups(['Default', 'scheduleEntry:update'])] + #[Groups(['read', 'write'])] public ?float $left = 0; /** @@ -100,7 +98,7 @@ class ScheduleEntry extends BaseEntity implements BelongsToCampInterface { * @ORM\Column(type="float", nullable=true) */ #[ApiProperty(example: 0.4)] - #[Groups(['Default', 'scheduleEntry:update'])] + #[Groups(['read', 'write'])] public ?float $width = 1; #[ApiProperty(readable: false)] @@ -112,6 +110,7 @@ public function getCamp(): ?Camp { * The day on which this schedule entry starts. */ #[ApiProperty(writable: false, example: '/days/1a2b3c4d')] + #[Groups(['read'])] public function getDay(): Day { $dayNumber = $this->getDayNumber(); @@ -129,6 +128,7 @@ public function getNumberingStyle(): ?string { * The day number of the day on which this schedule entry starts. */ #[ApiProperty(example: '1')] + #[Groups(['read'])] public function getDayNumber(): int { return 1 + floor($this->periodOffset / (24 * 60)); } @@ -139,6 +139,7 @@ public function getDayNumber(): int { * second entry on a given day, its number will be 2. */ #[ApiProperty(example: '2')] + #[Groups(['read'])] public function getScheduleEntryNumber(): int { $dayOffset = floor($this->periodOffset / (24 * 60)) * 24 * 60; @@ -184,6 +185,7 @@ public function getScheduleEntryNumber(): int { * defined by the activity's category. */ #[ApiProperty(example: '1.b')] + #[Groups(['read'])] public function getNumber(): string { $dayNumber = $this->getDayNumber(); $scheduleEntryNumber = $this->getScheduleEntryNumber(); diff --git a/api/src/Entity/User.php b/api/src/Entity/User.php index fc238c2382..e9661f9302 100644 --- a/api/src/Entity/User.php +++ b/api/src/Entity/User.php @@ -27,21 +27,19 @@ 'post' => [ 'security' => 'true', // allow unauthenticated clients to create (register) users 'input_formats' => ['jsonld', 'jsonapi', 'json'], - 'validation_groups' => ['Default', 'user:create'], + 'validation_groups' => ['Default', 'create'], + 'denormalization_context' => ['groups' => ['write', 'create']], ], ], itemOperations: [ 'get' => ['security' => 'is_granted("ROLE_ADMIN") or object == user'], 'patch' => [ 'security' => 'is_granted("ROLE_ADMIN") or object == user', - 'denormalization_context' => [ - 'groups' => 'user:update', - 'allow_extra_attributes' => false, - ], ], 'delete' => ['security' => 'is_granted("ROLE_ADMIN") and !object.ownsCamps()'], ], - denormalizationContext: ['groups' => ['Default']], + denormalizationContext: ['groups' => ['write']], + normalizationContext: ['groups' => ['read']], )] class User extends BaseEntity implements UserInterface, PasswordAuthenticatedUserInterface { /** @@ -69,7 +67,7 @@ class User extends BaseEntity implements UserInterface, PasswordAuthenticatedUse #[Assert\NotBlank] #[Assert\Email] #[ApiProperty(example: 'bi-pi@example.com')] - #[Groups(['Default', 'user:update'])] + #[Groups(['read', 'write'])] public ?string $email = null; /** @@ -81,7 +79,7 @@ class User extends BaseEntity implements UserInterface, PasswordAuthenticatedUse #[Assert\NotBlank] #[Assert\Regex(pattern: '/^[a-z0-9_.-]+$/')] #[ApiProperty(example: 'bipi')] - #[Groups(['Default'])] + #[Groups(['read', 'create'])] public ?string $username = null; /** @@ -92,7 +90,7 @@ class User extends BaseEntity implements UserInterface, PasswordAuthenticatedUse #[InputFilter\Trim] #[InputFilter\CleanHTML] #[ApiProperty(example: 'Robert')] - #[Groups(['Default', 'user:update'])] + #[Groups(['read', 'write'])] public ?string $firstname = null; /** @@ -103,7 +101,7 @@ class User extends BaseEntity implements UserInterface, PasswordAuthenticatedUse #[InputFilter\Trim] #[InputFilter\CleanHTML] #[ApiProperty(example: 'Baden-Powell')] - #[Groups(['Default', 'user:update'])] + #[Groups(['read', 'write'])] public ?string $surname = null; /** @@ -114,7 +112,7 @@ class User extends BaseEntity implements UserInterface, PasswordAuthenticatedUse #[InputFilter\Trim] #[InputFilter\CleanHTML] #[ApiProperty(example: 'Bi-Pi')] - #[Groups(['Default', 'user:update'])] + #[Groups(['read', 'write'])] public ?string $nickname = null; /** @@ -125,7 +123,7 @@ class User extends BaseEntity implements UserInterface, PasswordAuthenticatedUse #[InputFilter\Trim] #[Assert\Locale] #[ApiProperty(example: 'en')] - #[Groups(['Default', 'user:update'])] + #[Groups(['read', 'write'])] public ?string $language = null; /** @@ -149,10 +147,10 @@ class User extends BaseEntity implements UserInterface, PasswordAuthenticatedUse * A new password for this user. At least 8 characters. */ #[SerializedName('password')] - #[Assert\NotBlank(groups: ['user:create'])] + #[Assert\NotBlank(groups: ['create'])] #[Assert\Length(min: 8)] #[ApiProperty(readable: false, writable: true, example: 'learning-by-doing-101')] - #[Groups(['Default', 'user:update'])] + #[Groups(['read', 'write'])] public ?string $plainPassword = null; public function __construct() { @@ -166,6 +164,7 @@ public function __construct() { * @return null|string */ #[ApiProperty(example: 'Robert Baden-Powell')] + #[Groups(['read'])] public function getDisplayName(): ?string { if (!empty($this->nickname)) { return $this->nickname; diff --git a/api/src/Serializer/Normalizer/CircularReferenceDetectingHalItemNormalizer.php b/api/src/Serializer/Normalizer/CircularReferenceDetectingHalItemNormalizer.php new file mode 100644 index 0000000000..ec7b35ca69 --- /dev/null +++ b/api/src/Serializer/Normalizer/CircularReferenceDetectingHalItemNormalizer.php @@ -0,0 +1,103 @@ + ['self' => ['href' => $this->iriConverter->getIriFromItem($object)]]]; + }; + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $itemDataProvider, $allowPlainIdentifiers, $defaultContext, $dataTransformers, $resourceMetadataFactory, $resourceAccessChecker); + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null): bool { + return $this->decorated->supportsNormalization($data, $format); + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []) { + if ($this->isHalCircularReference($object, $context)) { + return $this->handleHalCircularReference($object, $format, $context); + } + + return $this->decorated->normalize($object, $format, $context); + } + + public function setSerializer(SerializerInterface $serializer) { + if ($this->decorated instanceof SerializerAwareInterface) { + $this->decorated->setSerializer($serializer); + } + } + + /** + * Detects if the configured circular reference limit is reached. + * + * @throws CircularReferenceException + * + * @return bool + */ + protected function isHalCircularReference(object $object, array &$context) { + $objectHash = spl_object_hash($object); + + $circularReferenceLimit = $context[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT] ?? $this->defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT]; + if (isset($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash])) { + if ($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash] >= $circularReferenceLimit) { + unset($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash]); + + return true; + } + + ++$context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash]; + } else { + $context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash] = 1; + } + + return false; + } + + /** + * Handles a circular reference. + * + * If a circular reference handler is set, it will be called. Otherwise, a + * {@class CircularReferenceException} will be thrown. + * + * @final + * + * @throws CircularReferenceException + */ + protected function handleHalCircularReference(object $object, string $format = null, array $context = []) { + $circularReferenceHandler = $context[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER] ?? $this->defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER]; + if ($circularReferenceHandler) { + return $circularReferenceHandler($object, $format, $context); + } + + throw new CircularReferenceException(sprintf('A circular reference has been detected when serializing the object of class "%s" (configured limit: %d).', get_debug_type($object), $context[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT] ?? $this->defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT])); + } +} diff --git a/api/src/Serializer/Normalizer/RelatedCollectionLinkNormalizer.php b/api/src/Serializer/Normalizer/RelatedCollectionLinkNormalizer.php index 09a170dd63..7d31ae54cc 100644 --- a/api/src/Serializer/Normalizer/RelatedCollectionLinkNormalizer.php +++ b/api/src/Serializer/Normalizer/RelatedCollectionLinkNormalizer.php @@ -115,7 +115,7 @@ public function getRelatedCollectionHref($object, $rel, array $context = []): st $resourceClass = $context['resource_class'] ?? get_class($object); if ($this->nameConverter instanceof AdvancedNameConverterInterface) { - $rel = $this->nameConverter->denormalize($rel, $resourceClass, null, $context); + $rel = $this->nameConverter->denormalize($rel, $resourceClass, null, array_merge($context, ['groups' => ['read']])); } try { diff --git a/api/src/Serializer/PreventAutomaticEmbeddingPropertyMetadataFactory.php b/api/src/Serializer/PreventAutomaticEmbeddingPropertyMetadataFactory.php new file mode 100644 index 0000000000..8a54d2f9f6 --- /dev/null +++ b/api/src/Serializer/PreventAutomaticEmbeddingPropertyMetadataFactory.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace App\Serializer; + +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; + +/** + * This is meant as a thin wrapper around + * ApiPlatform\Core\Metadata\Property\Factory\SerializerPropertyMetadataFactory. + * That class implements the automatic embedding logic based on serialization groups, + * described in https://api-platform.com/docs/core/serialization/#embedding-relations. + * + * However, we have our own system for serialization groups and embedding entities, + * with general groups 'read', 'write', 'create', 'update' etc. instead of entity- + * specific groups as they are used in the API platform docs ('book', 'book:update'). + * Therefore, we don't want relations to be automatically embedded as soon as there + * is a property with a matching serialization group on the related entity. + * + * As an example, we don't want the author to be embedded in the book here: + * + * #[ApiResource(normalization_context: ['groups' => ['read']])] + * class Book { + * #[Groups('read')] + * public ?Person $author = null; + * } + * + * #[ApiResource(normalization_context: ['groups' => ['read']])] + * class Person { + * #[Groups('read')] + * public string $name = ''; + * } + * + * To prevent the author from being embedded due to just the 'read' group, this + * class should be inserted just around SerializerPropertyMetadataFactory in the + * decorator chain. It will undo only the undesired changes to the property + * metadata that SerializerPropertyMetadataFactory adds. + * + * Currently, the SerializerPropertyMetadataFactory has a decoration priority of + * 30, so this class should be assigned a priority of 29. + * https://github.com/api-platform/core/blob/main/src/Bridge/Symfony/Bundle/Resources/config/metadata/metadata.xml#L65 + */ +final class PreventAutomaticEmbeddingPropertyMetadataFactory implements PropertyMetadataFactoryInterface { + public function __construct(private PropertyMetadataFactoryInterface $decorated) { + } + + /** + * {@inheritdoc} + */ + public function create(string $resourceClass, string $property, array $options = []): PropertyMetadata { + $propertyMetadata = $this->decorated->create($resourceClass, $property, $options); + + return new PropertyMetadata( + $propertyMetadata->getType(), + $propertyMetadata->getDescription(), + $propertyMetadata->isReadable(), + $propertyMetadata->isWritable(), + null, // reset readableLink to null + null, // reset writableLink to null + $propertyMetadata->isRequired(), + $propertyMetadata->isIdentifier(), + $propertyMetadata->getIri(), + $propertyMetadata->getChildInherited(), + $propertyMetadata->getAttributes(), + $propertyMetadata->getSubresource(), + $propertyMetadata->isInitializable(), + $propertyMetadata->getDefault(), + $propertyMetadata->getExample(), + $propertyMetadata->getSchema() + ); + } +} diff --git a/api/src/Serializer/SerializerContextBuilder.php b/api/src/Serializer/SerializerContextBuilder.php new file mode 100644 index 0000000000..c7a5e2a3b2 --- /dev/null +++ b/api/src/Serializer/SerializerContextBuilder.php @@ -0,0 +1,38 @@ +decorated = $decorated; + } + + public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array { + $context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes); + + if ($normalization) { + $context['skip_null_values'] = false; + } + + if (!$normalization) { + $context['allow_extra_attributes'] = false; + } + + return $context; + } +} diff --git a/api/src/Validator/MaterialItemUpdateGroupSequence.php b/api/src/Validator/MaterialItemUpdateGroupSequence.php index 2e79219b47..916b61447b 100644 --- a/api/src/Validator/MaterialItemUpdateGroupSequence.php +++ b/api/src/Validator/MaterialItemUpdateGroupSequence.php @@ -7,6 +7,6 @@ class MaterialItemUpdateGroupSequence implements ValidationGroupsGeneratorInterface { public function __invoke($object) { - return new GroupSequence(['materialItem:update', 'Default']); // now, no matter which is first in the class declaration, it will be tested in this order. + return new GroupSequence(['update', 'Default']); // now, no matter which is first in the class declaration, it will be tested in this order. } } diff --git a/api/tests/Api/Activities/CreateActivityTest.php b/api/tests/Api/Activities/CreateActivityTest.php index 78ad0ddcaa..b50ee2707c 100644 --- a/api/tests/Api/Activities/CreateActivityTest.php +++ b/api/tests/Api/Activities/CreateActivityTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Api\Activities; +use ApiPlatform\Core\Api\OperationType; use App\Entity\Activity; use App\Tests\Api\ECampApiTestCase; @@ -67,6 +68,8 @@ public function testCreateActivityAllowsMissingLocation() { public function getExampleWritePayload($attributes = [], $except = []) { return $this->getExamplePayload( Activity::class, + OperationType::COLLECTION, + 'post', array_merge(['category' => $this->getIriFor('category1')], $attributes), [], $except @@ -76,7 +79,13 @@ public function getExampleWritePayload($attributes = [], $except = []) { public function getExampleReadPayload($attributes = [], $except = []) { return $this->getExamplePayload( Activity::class, - $attributes, + OperationType::ITEM, + 'get', + array_merge([ + '_links' => [ + 'contentNodes' => [], + ], + ], $attributes), ['category'], $except ); diff --git a/api/tests/Api/ActivityResponsibles/CreateActivityResponsibleTest.php b/api/tests/Api/ActivityResponsibles/CreateActivityResponsibleTest.php index f2f7c8f1ef..403b4374a7 100644 --- a/api/tests/Api/ActivityResponsibles/CreateActivityResponsibleTest.php +++ b/api/tests/Api/ActivityResponsibles/CreateActivityResponsibleTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Api\ActivityResponsibles; +use ApiPlatform\Core\Api\OperationType; use App\Entity\ActivityResponsible; use App\Tests\Api\ECampApiTestCase; @@ -68,6 +69,8 @@ public function testValidatesDuplicateActivityResponsible() { public function getExampleWritePayload($attributes = [], $except = []) { return $this->getExamplePayload( ActivityResponsible::class, + OperationType::COLLECTION, + 'post', array_merge([ 'activity' => $this->getIriFor('activity2'), 'campCollaboration' => $this->getIriFor('campCollaboration1'), @@ -80,6 +83,8 @@ public function getExampleWritePayload($attributes = [], $except = []) { public function getExampleReadPayload($attributes = [], $except = []) { return $this->getExamplePayload( ActivityResponsible::class, + OperationType::ITEM, + 'get', $attributes, ['activity', 'campCollaboration'], $except diff --git a/api/tests/Api/CampCollaborations/CreateCampCollaborationTest.php b/api/tests/Api/CampCollaborations/CreateCampCollaborationTest.php index acaee69c87..52a85945e1 100644 --- a/api/tests/Api/CampCollaborations/CreateCampCollaborationTest.php +++ b/api/tests/Api/CampCollaborations/CreateCampCollaborationTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Api\CampCollaborations; +use ApiPlatform\Core\Api\OperationType; use App\Entity\CampCollaboration; use App\Tests\Api\ECampApiTestCase; @@ -123,6 +124,8 @@ public function testCreateCampCollaborationValidatesMissingRole() { public function getExampleWritePayload($attributes = [], $except = []) { return $this->getExamplePayload( CampCollaboration::class, + OperationType::COLLECTION, + 'post', array_merge([ 'inviteEmail' => null, 'user' => $this->getIriFor('user1'), @@ -136,6 +139,8 @@ public function getExampleWritePayload($attributes = [], $except = []) { public function getExampleReadPayload($attributes = [], $except = []) { return $this->getExamplePayload( CampCollaboration::class, + OperationType::ITEM, + 'get', array_merge([ '_links' => [ 'user' => ['href' => $this->getIriFor('user1')], diff --git a/api/tests/Api/Camps/CreateCampTest.php b/api/tests/Api/Camps/CreateCampTest.php index 3ee10ceb8b..c4ad15496f 100644 --- a/api/tests/Api/Camps/CreateCampTest.php +++ b/api/tests/Api/Camps/CreateCampTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Api\Camps; +use ApiPlatform\Core\Api\OperationType; use App\Entity\Camp; use App\Entity\User; use App\Tests\Api\ECampApiTestCase; @@ -588,12 +589,14 @@ public function testCreateCampValidatesLongAddressCity() { } public function getExampleWritePayload($attributes = [], $except = []) { - return $this->getExamplePayload(Camp::class, $attributes, [], $except); + return $this->getExamplePayload(Camp::class, OperationType::COLLECTION, 'post', $attributes, [], $except); } public function getExampleReadPayload($attributes = [], $except = []) { return $this->getExamplePayload( Camp::class, + OperationType::ITEM, + 'get', $attributes, ['periods'], $except diff --git a/api/tests/Api/Categories/CreateCategoryTest.php b/api/tests/Api/Categories/CreateCategoryTest.php index 60dac99264..4ecbf84ed2 100644 --- a/api/tests/Api/Categories/CreateCategoryTest.php +++ b/api/tests/Api/Categories/CreateCategoryTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Api\Categories; +use ApiPlatform\Core\Api\OperationType; use App\Entity\Category; use App\Entity\ContentNode; use App\Tests\Api\ECampApiTestCase; @@ -143,6 +144,8 @@ public function testCreateCategoryValidatesInvalidNumberingStyle() { public function getExampleWritePayload($attributes = [], $except = []) { return $this->getExamplePayload( Category::class, + OperationType::COLLECTION, + 'post', array_merge([ 'camp' => $this->getIriFor('camp1'), 'preferredContentTypes' => [$this->getIriFor('contentType1')], @@ -155,6 +158,8 @@ public function getExampleWritePayload($attributes = [], $except = []) { public function getExampleReadPayload($attributes = [], $except = []) { return $this->getExamplePayload( Category::class, + OperationType::ITEM, + 'get', array_merge([ '_links' => [ 'contentNodes' => [], diff --git a/api/tests/Api/ContentNodes/CreateContentNodeTest.php b/api/tests/Api/ContentNodes/CreateContentNodeTest.php index 93ae150d0b..732f225889 100644 --- a/api/tests/Api/ContentNodes/CreateContentNodeTest.php +++ b/api/tests/Api/ContentNodes/CreateContentNodeTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Api\ContentNodes; +use ApiPlatform\Core\Api\OperationType; use App\Entity\ContentNode; use App\Tests\Api\ECampApiTestCase; @@ -88,6 +89,8 @@ public function testCreateContentNodeValidatesMissingContentType() { public function getExampleWritePayload($attributes = [], $except = []) { return $this->getExamplePayload( ContentNode::class, + OperationType::COLLECTION, + 'post', array_merge([ 'parent' => $this->getIriFor('contentNode1'), 'contentType' => $this->getIriFor('contentTypeColumnLayout'), @@ -100,6 +103,8 @@ public function getExampleWritePayload($attributes = [], $except = []) { public function getExampleReadPayload($attributes = [], $except = []) { return $this->getExamplePayload( ContentNode::class, + OperationType::ITEM, + 'get', $attributes, ['parent', 'contentType'], $except diff --git a/api/tests/Api/DayResponsibles/CreateDayResponsibleTest.php b/api/tests/Api/DayResponsibles/CreateDayResponsibleTest.php index c6e267e36a..cbce3b1462 100644 --- a/api/tests/Api/DayResponsibles/CreateDayResponsibleTest.php +++ b/api/tests/Api/DayResponsibles/CreateDayResponsibleTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Api\DayResponsibles; +use ApiPlatform\Core\Api\OperationType; use App\Entity\DayResponsible; use App\Tests\Api\ECampApiTestCase; @@ -68,6 +69,8 @@ public function testValidatesDuplicateDayResponsible() { public function getExampleWritePayload($attributes = [], $except = []) { return $this->getExamplePayload( DayResponsible::class, + OperationType::COLLECTION, + 'post', array_merge([ 'day' => $this->getIriFor('day3period1'), 'campCollaboration' => $this->getIriFor('campCollaboration1'), @@ -80,6 +83,8 @@ public function getExampleWritePayload($attributes = [], $except = []) { public function getExampleReadPayload($attributes = [], $except = []) { return $this->getExamplePayload( DayResponsible::class, + OperationType::ITEM, + 'get', $attributes, ['day', 'campCollaboration'], $except diff --git a/api/tests/Api/ECampApiTestCase.php b/api/tests/Api/ECampApiTestCase.php index e913c922dc..171fa8148b 100644 --- a/api/tests/Api/ECampApiTestCase.php +++ b/api/tests/Api/ECampApiTestCase.php @@ -110,10 +110,11 @@ protected function getEntityManager(): EntityManagerInterface { return $this->entityManager; } - protected function getExamplePayload(string $resourceClass, array $attributes = [], array $exceptExamples = [], array $exceptAttributes = []): array { - $shortName = $this->getResourceMetadataFactory()->create($resourceClass)->getShortName(); - $schema = $this->getSchemaFactory()->buildSchema($resourceClass, 'json', Schema::TYPE_INPUT, 'POST'); - $properties = ($schema->getDefinitions()[$shortName] ?? $schema->getDefinitions()[$shortName.'-Default'])['properties']; + protected function getExamplePayload(string $resourceClass, string $operationType, string $operationName, array $attributes = [], array $exceptExamples = [], array $exceptAttributes = []): array { + $schema = $this->getSchemaFactory()->buildSchema($resourceClass, 'json', 'get' === $operationName ? Schema::TYPE_OUTPUT : Schema::TYPE_INPUT, $operationType, $operationName); + preg_match('/\/([^\/]+)$/', $schema['$ref'], $matches); + $schemaName = $matches[1]; + $properties = $schema->getDefinitions()[$schemaName]['properties'] ?? []; $writableProperties = array_filter($properties, fn ($property) => !($property['readOnly'] ?? false)); $writablePropertiesWithExample = array_filter($writableProperties, fn ($property) => ($property['example'] ?? false)); $examples = array_map(fn ($property) => $property['example'] ?? $property['default'] ?? null, $writablePropertiesWithExample); diff --git a/api/tests/Api/MaterialItems/CreateMaterialItemTest.php b/api/tests/Api/MaterialItems/CreateMaterialItemTest.php index 9d3bac1aff..a2c454c35c 100644 --- a/api/tests/Api/MaterialItems/CreateMaterialItemTest.php +++ b/api/tests/Api/MaterialItems/CreateMaterialItemTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Api\MaterialItems; +use ApiPlatform\Core\Api\OperationType; use App\Entity\MaterialItem; use App\Tests\Api\ECampApiTestCase; @@ -164,6 +165,8 @@ public function testCreateMaterialItemAllowsMissingUnit() { public function getExampleWritePayload($attributes = [], $except = []) { return $this->getExamplePayload( MaterialItem::class, + OperationType::COLLECTION, + 'post', array_merge([ 'materialList' => $this->getIriFor('materialList1'), 'period' => $this->getIriFor('period1'), @@ -177,6 +180,8 @@ public function getExampleWritePayload($attributes = [], $except = []) { public function getExampleReadPayload($attributes = [], $except = []) { return $this->getExamplePayload( MaterialItem::class, + OperationType::ITEM, + 'get', $attributes, ['materialList', 'period', 'contentNode'], $except diff --git a/api/tests/Api/MaterialLists/CreateMaterialListTest.php b/api/tests/Api/MaterialLists/CreateMaterialListTest.php index c8a4be90e2..7fb2037935 100644 --- a/api/tests/Api/MaterialLists/CreateMaterialListTest.php +++ b/api/tests/Api/MaterialLists/CreateMaterialListTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Api\MaterialLists; +use ApiPlatform\Core\Api\OperationType; use App\Entity\MaterialList; use App\Tests\Api\ECampApiTestCase; @@ -51,6 +52,8 @@ public function testCreateMaterialListValidatesMissingName() { public function getExampleWritePayload($attributes = [], $except = []) { return $this->getExamplePayload( MaterialList::class, + OperationType::COLLECTION, + 'post', array_merge(['camp' => $this->getIriFor('camp1')], $attributes), [], $except @@ -60,6 +63,8 @@ public function getExampleWritePayload($attributes = [], $except = []) { public function getExampleReadPayload($attributes = [], $except = []) { return $this->getExamplePayload( MaterialList::class, + OperationType::ITEM, + 'get', $attributes, ['camp'], $except diff --git a/api/tests/Api/Periods/CreatePeriodTest.php b/api/tests/Api/Periods/CreatePeriodTest.php index f03f7bd855..d91f6d0334 100644 --- a/api/tests/Api/Periods/CreatePeriodTest.php +++ b/api/tests/Api/Periods/CreatePeriodTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Api\Periods; +use ApiPlatform\Core\Api\OperationType; use App\Entity\Period; use App\Tests\Api\ECampApiTestCase; @@ -150,6 +151,8 @@ public function testCreatePeriodValidatesStartAfterEnd() { public function getExampleWritePayload($attributes = [], $except = []) { return $this->getExamplePayload( Period::class, + OperationType::COLLECTION, + 'post', array_merge(['camp' => $this->getIriFor('camp1')], $attributes), [], $except @@ -159,6 +162,8 @@ public function getExampleWritePayload($attributes = [], $except = []) { public function getExampleReadPayload($attributes = [], $except = []) { return $this->getExamplePayload( Period::class, + OperationType::ITEM, + 'get', $attributes, ['camp'], $except diff --git a/api/tests/Api/ScheduleEntries/CreateScheduleEntryTest.php b/api/tests/Api/ScheduleEntries/CreateScheduleEntryTest.php index 31c90828de..242c1cf803 100644 --- a/api/tests/Api/ScheduleEntries/CreateScheduleEntryTest.php +++ b/api/tests/Api/ScheduleEntries/CreateScheduleEntryTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Api\ScheduleEntries; +use ApiPlatform\Core\Api\OperationType; use App\Entity\ScheduleEntry; use App\Tests\Api\ECampApiTestCase; @@ -141,6 +142,8 @@ public function testCreateScheduleEntryUsesDefaultForMissingWidth() { public function getExampleWritePayload($attributes = [], $except = []) { return $this->getExamplePayload( ScheduleEntry::class, + OperationType::COLLECTION, + 'post', array_merge([ 'period' => $this->getIriFor('period1'), 'activity' => $this->getIriFor('activity1'), @@ -153,6 +156,8 @@ public function getExampleWritePayload($attributes = [], $except = []) { public function getExampleReadPayload($attributes = [], $except = []) { return $this->getExamplePayload( ScheduleEntry::class, + OperationType::ITEM, + 'get', $attributes, ['period', 'activity'], $except diff --git a/api/tests/Api/Users/CreateUserTest.php b/api/tests/Api/Users/CreateUserTest.php index a2ad02ffcc..3a57387ec8 100644 --- a/api/tests/Api/Users/CreateUserTest.php +++ b/api/tests/Api/Users/CreateUserTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Api\Users; +use ApiPlatform\Core\Api\OperationType; use App\Entity\User; use App\Tests\Api\ECampApiTestCase; @@ -393,10 +394,10 @@ public function testCreateUserValidatesBlankPassword() { } public function getExampleWritePayload($attributes = [], $except = []) { - return $this->getExamplePayload(User::class, $attributes, [], $except); + return $this->getExamplePayload(User::class, OperationType::COLLECTION, 'post', $attributes, [], $except); } public function getExampleReadPayload($attributes = [], $except = []) { - return $this->getExamplePayload(User::class, $attributes, [], $except); + return $this->getExamplePayload(User::class, OperationType::ITEM, 'get', $attributes, [], $except); } } diff --git a/api/tests/Serializer/Normalizer/CircularReferenceDetectingHalItemNormalizerTest.php b/api/tests/Serializer/Normalizer/CircularReferenceDetectingHalItemNormalizerTest.php new file mode 100644 index 0000000000..da8ffdfe56 --- /dev/null +++ b/api/tests/Serializer/Normalizer/CircularReferenceDetectingHalItemNormalizerTest.php @@ -0,0 +1,123 @@ +decoratedMock = $this->createMock(ContextAwareNormalizerInterface::class); + $this->iriConverterMock = $this->createMock(IriConverterInterface::class); + + $this->normalizer = new CircularReferenceDetectingHalItemNormalizer( + $this->decoratedMock, + $this->createMock(PropertyNameCollectionFactoryInterface::class), + $this->createMock(PropertyMetadataFactoryInterface::class), + $this->iriConverterMock, + $this->createMock(ResourceClassResolverInterface::class) + ); + + $this->normalizer->setSerializer($this->createMock(SerializerInterface::class)); + } + + public function testDelegatesSupportCheckToDecorated() { + $this->decoratedMock + ->expects($this->exactly(2)) + ->method('supportsNormalization') + ->willReturnOnConsecutiveCalls(true, false) + ; + + $this->assertTrue($this->normalizer->supportsNormalization([])); + $this->assertFalse($this->normalizer->supportsNormalization([])); + } + + public function testDelegatesNormalizeToDecorated() { + // given + $resource = new Dummy(); + $delegatedResult = [ + 'hello' => 'world', + '_links' => [ + 'items' => [['href' => '/children/1']], + ], + ]; + $this->decoratedMock->expects($this->once()) + ->method('normalize') + ->willReturn($delegatedResult) + ; + + // when + $result = $this->normalizer->normalize($resource, null, []); + + // then + $this->assertEquals($delegatedResult, $result); + } + + public function testNormalizeDetectsCircularReference() { + // given + $normalizer = $this->normalizer; + $resource = new Dummy(); + $related = new RelatedDummy(); + $resource->related = $related; + $related->owner = $resource; + + $this->decoratedMock + ->method('normalize') + ->willReturnCallback(function ($object, $format, $context) use ($normalizer) { + $result = ['serialized_id' => $object->id]; + if ($object instanceof Dummy) { + $result = array_merge($result, ['related' => $normalizer->normalize($object->related, $format, $context)]); + } + if ($object instanceof RelatedDummy) { + $result = array_merge($result, ['owner' => $normalizer->normalize($object->owner, $format, $context)]); + } + + return $result; + }) + ; + $this->iriConverterMock + ->method('getIriFromItem') + ->willReturnCallback(fn ($object) => '/'.$object->id) + ; + + // when + /** @var Dummy $result */ + $result = $this->normalizer->normalize($resource, null, []); + + // then + $this->assertEquals(['_links' => ['self' => ['href' => '/dummy']]], $result['related']['owner']); + } +} + +class Dummy { + public string $id = 'dummy'; + public ?RelatedDummy $related = null; +} + +class RelatedDummy { + public string $id = 'related'; + public ?Dummy $owner = null; +} diff --git a/api/tests/Serializer/PreventAutomaticEmbeddingPropertyMetadataFactoryTest.php b/api/tests/Serializer/PreventAutomaticEmbeddingPropertyMetadataFactoryTest.php new file mode 100644 index 0000000000..9d0df4ce39 --- /dev/null +++ b/api/tests/Serializer/PreventAutomaticEmbeddingPropertyMetadataFactoryTest.php @@ -0,0 +1,66 @@ +createMock(PropertyMetadataFactoryInterface::class); + $propertyMetadata = new PropertyMetadata( + $this->createMock(Type::class), + 'description', + true, + true, + true, + true, + true, + true, + '/iri/of/entity', + null, + [], + new SubresourceMetadata(Dummy::class, true, 3), + true, + null, + null, + [] + ); + $decorated->expects($this->once()) + ->method('create') + ->willReturn($propertyMetadata) + ; + $factory = new PreventAutomaticEmbeddingPropertyMetadataFactory($decorated); + + // when + $result = $factory->create(Dummy::class, 'myProperty', []); + + // then + $this->assertEquals($propertyMetadata->getType(), $result->getType()); + $this->assertEquals($propertyMetadata->getDescription(), $result->getDescription()); + $this->assertEquals($propertyMetadata->isReadable(), $result->isReadable()); + $this->assertEquals($propertyMetadata->isWritable(), $result->isWritable()); + $this->assertEquals(null, $result->isReadableLink()); + $this->assertEquals(null, $result->isWritableLink()); + $this->assertEquals($propertyMetadata->isRequired(), $result->isRequired()); + $this->assertEquals($propertyMetadata->isIdentifier(), $result->isIdentifier()); + $this->assertEquals($propertyMetadata->getIri(), $result->getIri()); + $this->assertEquals($propertyMetadata->getAttributes(), $result->getAttributes()); + $this->assertEquals($propertyMetadata->getSubresource(), $result->getSubresource()); + $this->assertEquals($propertyMetadata->isInitializable(), $result->isInitializable()); + $this->assertEquals($propertyMetadata->getDefault(), $result->getDefault()); + $this->assertEquals($propertyMetadata->getExample(), $result->getExample()); + $this->assertEquals($propertyMetadata->getSchema(), $result->getSchema()); + } +} + +class Dummy { +} diff --git a/api/tests/Serializer/SerializerContextBuilderTest.php b/api/tests/Serializer/SerializerContextBuilderTest.php new file mode 100644 index 0000000000..1d7a55c4c2 --- /dev/null +++ b/api/tests/Serializer/SerializerContextBuilderTest.php @@ -0,0 +1,79 @@ +decoratedMock = $this->createMock(SerializerContextBuilderInterface::class); + + $this->contextBuilder = new SerializerContextBuilder($this->decoratedMock); + } + + public function testAddsSkipNullValuesFalseWhenNormalizing() { + $request = $this->createMock(Request::class); + $this->decoratedMock + ->expects($this->exactly(1)) + ->method('createFromRequest') + ->willReturn([]) + ; + + $result = $this->contextBuilder->createFromRequest($request, true); + + $this->assertEquals(['skip_null_values' => false], $result); + } + + public function testDoesntAddSkipNullValuesFalseWhenDenormalizing() { + $request = $this->createMock(Request::class); + $this->decoratedMock + ->expects($this->exactly(1)) + ->method('createFromRequest') + ->willReturn([]) + ; + + $result = $this->contextBuilder->createFromRequest($request, false); + + $this->assertNotEquals(['skip_null_values' => false], $result); + } + + public function testDoesntAddAllowExtraAttributesFalseWhenNormalizing() { + $request = $this->createMock(Request::class); + $this->decoratedMock + ->expects($this->exactly(1)) + ->method('createFromRequest') + ->willReturn([]) + ; + + $result = $this->contextBuilder->createFromRequest($request, true); + + $this->assertNotEquals(['allow_extra_attributes' => false], $result); + } + + public function testAddsAllowExtraAttributesFalseWhenDenormalizing() { + $request = $this->createMock(Request::class); + $this->decoratedMock + ->expects($this->exactly(1)) + ->method('createFromRequest') + ->willReturn([]) + ; + + $result = $this->contextBuilder->createFromRequest($request, false); + + $this->assertEquals(['allow_extra_attributes' => false], $result); + } +}