Many parts of the JSON:API codebase require the resource type of a route and/or entity that is currently being processed. Whether it is to alias a field, validate a filter path, test if a field is disabled, or check if certain features are enabled. This means that the resource type repository is loaded and called frequently throughout the codebase. In some cases, this can be a performance bottleneck (#3014232-27: [regression] ResourceTypeRepository is significantly slower in JSON:API 2, becomes noticeable when handling hundreds of interlinked resources), but also a code smell.

This issue proposes to wrap entity objects in a value object that contains the associated resource type for that entity.

Some benefits:

  • This introduces ResourceIdentifier(Interface|Trait), I think this will lead to further simplifications in the future.
  • We landed #2995960: Add a Link and LinkCollection class to support RFC8288 web linking., which makes Links value object that we can pass into JsonApiDocumentTopLevel. With this, we can push pass links into ResourceObjects prior to normalization too. That will standardize/validate the LinkCollection pattern.
  • This will result in fewer usages of the deprecated LinkManager.
  • This will simplify the IncludeResolver
  • Eliminate the need for a global-like $context['resource_type'] in the normalization system
  • The JSON:API normalizer will no longer need to provide a normalizer for entity objects and therefore can't conflict with existing entity normalizers. If we have value objects in our namespace for everything above the data type level, we can begin to ask ourselves, why do we still need jsonapi.serializer_do_not_use_removal_imminent (especially after #3022584: Consolidate and simplify NormalizerValue objects: introduce CacheableNormalization)?
CommentFileSizeAuthor
#94 3015438-94.patch110.62 KBwim leers
#94 interdiff.txt667 byteswim leers
#92 3015438-92.patch110.56 KBwim leers
#92 interdiff.txt3.03 KBwim leers
#89 3015438-89.patch110.47 KBgabesullice
#89 interdiff.txt1.44 KBgabesullice
#87 3015438-87.patch109.31 KBgabesullice
#87 interdiff.txt1.76 KBgabesullice
#82 3015438-82.patch107.77 KBwim leers
#82 interdiff.txt1.44 KBwim leers
#79 3015438-79.patch107.87 KBwim leers
#79 interdiff.txt1.26 KBwim leers
#78 3015438-78.patch108.19 KBwim leers
#78 interdiff.txt4.97 KBwim leers
#73 3015438-73.patch109.18 KBgabesullice
#73 interdiff.txt1.87 KBgabesullice
#71 3015438-71.patch107.53 KBgabesullice
#71 interdiff.txt911 bytesgabesullice
#69 3015438-69.patch107.52 KBgabesullice
#69 interdiff.txt1.12 KBgabesullice
#67 3015438-67.patch107.71 KBgabesullice
#64 3015438-64.patch121.36 KBwim leers
#59 3015438-59.patch121.85 KBgabesullice
#59 interdiff.txt572 bytesgabesullice
#58 3015438-58.patch122 KBgabesullice
#58 interdiff.txt484 bytesgabesullice
#57 3015438-57.patch122.06 KBwim leers
#57 interdiff.txt1.71 KBwim leers
#55 3015438-55.patch122 KBgabesullice
#55 interdiff.txt2.14 KBgabesullice
#53 3015438-53.patch121.44 KBgabesullice
#53 interdiff.txt660 bytesgabesullice
#51 3015438-51.patch121.4 KBgabesullice
#51 interdiff.txt16.2 KBgabesullice
#50 3015438-50.patch112.41 KBgabesullice
#50 interdiff.txt10.7 KBgabesullice
#46 3015438-46.patch106.42 KBgabesullice
#46 interdiff.txt5.08 KBgabesullice
#44 3015438-44.patch104.38 KBwim leers
#44 interdiff.txt684 byteswim leers
#42 3015438-41_smaller.patch104.38 KBwim leers
#41 3015438-41.patch109.6 KBwim leers
#41 interdiff.txt11.79 KBwim leers
#40 3015438-40.patch110.5 KBwim leers
#40 interdiff.txt893 byteswim leers
#38 3015438-38.patch110.45 KBwim leers
#38 interdiff.txt525 byteswim leers
#36 3015438-36.patch110.21 KBgabesullice
#32 3015438-32.patch110.39 KBwim leers
#32 interdiff.txt1018 byteswim leers
#30 3015438-30.patch110.27 KBgabesullice
#30 interdiff.txt2.7 KBgabesullice
#28 3015438-28.patch108.78 KBgabesullice
#25 3015438-25.patch112.12 KBgabesullice
#25 interdiff.txt1.29 KBgabesullice
#24 3015438-24.patch111.74 KBgabesullice
#24 interdiff.txt15.63 KBgabesullice
#21 3015438-21.patch107.67 KBgabesullice
#21 interdiff.txt11.18 KBgabesullice
#20 3015438-20.patch96.49 KBgabesullice
#20 interdiff.txt3.15 KBgabesullice
#18 3015438-18.patch97.3 KBgabesullice
#18 interdiff.txt3.33 KBgabesullice
#14 3015438-13.patch96.57 KBgabesullice
#14 3015438-13.smaller.patch94.14 KBgabesullice
#14 interdiff.txt20.15 KBgabesullice
#14 interdiff.txt20.15 KBgabesullice
#12 3015438-12.patch91.78 KBgabesullice
#12 interdiff.txt3.7 KBgabesullice
#11 3015438-11.patch90.72 KBgabesullice
#11 interdiff.txt1.18 KBgabesullice
#10 3015438-10.patch90.44 KBgabesullice
#10 interdiff.txt3.55 KBgabesullice
#9 3015438-9.patch87.88 KBgabesullice
#8 3015438-8.patch87.88 KBgabesullice
#6 3015438-6.patch87.88 KBgabesullice
#6 interdiff-cs-violations.txt3.54 KBgabesullice
#2 3015438-2.patch86.87 KBgabesullice

Comments

gabesullice created an issue. See original summary.

gabesullice’s picture

StatusFileSize
new86.87 KB

Here is my first iteration on this concept.

gabesullice’s picture

  1. +++ b/jsonapi.services.yml
    @@ -64,9 +64,9 @@ services:
    -    class: Drupal\jsonapi\Normalizer\ContentEntityNormalizer
    ...
    +    class: Drupal\jsonapi\Normalizer\ContentEntityDenormalizer
    
    @@ -75,8 +75,13 @@ services:
    -    class: Drupal\jsonapi\Normalizer\ConfigEntityNormalizer
    ...
    +    class: Drupal\jsonapi\Normalizer\ConfigEntityDenormalizer
    
    @@ -86,7 +91,7 @@ services:
           - { name: jsonapi_normalizer_do_not_use_removal_imminent, priority: 1 }
    

    Entity level normalizers now only handle deserialization.

  2. +++ b/jsonapi.services.yml
    @@ -75,8 +75,13 @@ services:
    +  serializer.normalizer.resource_object.jsonapi:
    +    class: Drupal\jsonapi\Normalizer\ResourceObjectNormalizer
    

    Normalization is handled by the ResourceObject normalizer.

  3. +++ b/jsonapi.services.yml
    @@ -86,7 +91,7 @@ services:
       serializer.normalizer.entity_reference_field.jsonapi:
         class: Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer
    -    arguments: ['@jsonapi.resource_type.repository', '@entity.repository']
    +    arguments: ['@entity.repository']
    

    The resource type repository is no longer needed here.

  4. +++ b/src/Controller/EntityResource.php
    @@ -1023,13 +1024,15 @@ class EntityResource {
    +  public static function getAccessCheckedResourceObject(EntityInterface $entity) {
    +    /* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository */
    +    $resource_type_repository = \Drupal::service('jsonapi.resource_type.repository');
    

    This needs to be updated to accept a resource object.

  5. +++ b/src/JsonApiResource/EntityCollection.php
    @@ -171,15 +170,9 @@ class EntityCollection implements \IteratorAggregate, \Countable {
    -      if ($resource instanceof EntityInterface) {
    -        $resource_identifier = ResourceIdentifier::fromEntity($resource);
    -        $dedupe_key = $resource_identifier->getTypeName() . ':' . $resource_identifier->getId();
    -      }
    -      else {
    -        $dedupe_key = $resource->getTypeName() . ':' . $resource->getId();
    -        if ($resource instanceof EntityAccessDeniedHttpException && ($error = $resource->getError()) && !is_null($error['relationship_field'])) {
    -          $dedupe_key .= ':' . $error['relationship_field'];
    -        }
    +      $dedupe_key = $resource->getTypeName() . ':' . $resource->getId();
    +      if ($resource instanceof EntityAccessDeniedHttpException && ($error = $resource->getError()) && !is_null($error['relationship_field'])) {
    +        $dedupe_key .= ':' . $error['relationship_field'];
    

    This is simplified because ResourceObject implements ResourceIdentifierInterface, like all other objects in an EntityCollection.

  6. +++ b/src/JsonApiResource/ResourceObject.php
    @@ -0,0 +1,184 @@
    +    $this->fields = $this->extractFields($entity);
    ...
    +  protected function extractFields(EntityInterface $entity) {
    +    assert($entity instanceof ContentEntityInterface || $entity instanceof ConfigEntityInterface);
    +    return $entity instanceof ContentEntityInterface
    +      ? $this->extractContentEntityFields($entity)
    +      : $this->extractConfigEntityFields($entity);
    ...
    +  protected function extractContentEntityFields(ContentEntityInterface $entity) {
    ...
    +  protected function extractConfigEntityFields(ConfigEntityInterface $entity) {
    

    @e0ipso, I think this begins to accomplish some of the things in #3014277: ResourceTypes should know about their fields. I'm interested how they could play together.

  7. +++ b/src/Normalizer/EntityReferenceFieldNormalizer.php
    @@ -123,34 +122,42 @@ class EntityReferenceFieldNormalizer extends FieldNormalizer {
    -      if ($this->isInternalResourceType($entity)) {
    +      if ($resource_object->getResourceType()->isInternal()) {
    

    This would eliminate between 5 and 11 seconds in #3014232-27: [regression] ResourceTypeRepository is significantly slower in JSON:API 2, becomes noticeable when handling hundreds of interlinked resources according to the linked profile. That is not reason enough to apply this patch, there are obviously much smaller static cache tricks we could employ. I do think it illustrates the point of this issue though: using a global object to store information that should be directly associated with the entity being processed has negative consequences.

gabesullice’s picture

Title: Wrap entity objects in a ResourceObject which carries a ResourceType » [WIP] Wrap entity objects in a ResourceObject which carries a ResourceType
Assigned: Unassigned » gabesullice

This is obviously still a WIP.

wim leers’s picture

I was going to ask how this would interact with @e0ipso's proposal in #3014277: ResourceTypes should know about their fields, but I see you're already thinking about that in #3.6. Great!

#3.7: "between 5 and 11 seconds" is sort of meaningless, we want to know the relative speedup. And I'm not sure that's actually true; it'd only be true if we don't commit the static caching patch at #3014232: [regression] ResourceTypeRepository is significantly slower in JSON:API 2, becomes noticeable when handling hundreds of interlinked resources. With that applied, the cost is only 423 ms, and getting that to zero will not be possible. Additionally, that profile includes JSON API Extras, which inherently increases wall time since it needs to load and inspect its config too.
However, that whole static cache/memory cache business in ResourceTypeRepository has always been a pain. So I most definitely agree with I do think it illustrates the point of this issue though: using a global object to store information that should be directly associated with the entity being processed has negative consequences..

I'm interested in exploring this further, but only post-2.0. Since all of this is an internal API anyway, we can do that.

+++ b/src/JsonApiResource/ResourceObject.php
@@ -0,0 +1,184 @@
+/**
+ * Represent a JSON API resource object.
+ *
+ * This value object wraps a Drupal entity so that it can carry a JSON API
+ * resource type object alongside it.
+ *
+ * @internal
+ */
+final class ResourceObject implements CacheableDependencyInterface, ResourceIdentifierInterface {

I definitely like this, not only because of the simplifications it brings (some of which you described in #3), but also because we then have a value object that clearly maps 1:1 to the JSON:API spec. I suspect this will make the codebase easier to maintain and grok.

gabesullice’s picture

StatusFileSize
new3.54 KB
new87.88 KB

Rerolled and fixed the CS violations.


it'd only be true if we don't commit the static caching patch...

Yep, which is why I said: "That is not reason enough to apply this patch, there are obviously much smaller static cache tricks we could employ."

With that applied, the cost is only 423 ms, and getting that to zero will not be possible.

And I think if we had applied this before trying a cache, then the static cache would have looked much less impressive at that point as well. My point was to illustrate that the bottleneck could be solved with a cache OR this. I think adding a cache was obviously the faster/less disruptive solution and I'm glad we did it, however it really only addressed the symptom, not the cause.

wim leers’s picture

Sorry for misunderstanding!

My point was to illustrate that the bottleneck could be solved with a cache OR this.

That makes sense.

I think adding a cache was obviously the faster/less disruptive solution and I'm glad we did it, however it really only addressed the symptom, not the cause.

I wholeheartedly agree!

gabesullice’s picture

StatusFileSize
new87.88 KB

Reroll.

gabesullice’s picture

StatusFileSize
new87.88 KB

Fix the most common failure, a leftover missing variable.

gabesullice’s picture

StatusFileSize
new3.55 KB
new90.44 KB

This should fix all the field_nonexistant errors.

gabesullice’s picture

StatusFileSize
new1.18 KB
new90.72 KB

I'm hoping this turns this patch green! 🤞

gabesullice’s picture

StatusFileSize
new3.7 KB
new91.78 KB

#11 was close! But this should do it :)

gabesullice’s picture

  1. +++ b/jsonapi.services.yml
    @@ -64,9 +64,9 @@ services:
    -  serializer.normalizer.entity.jsonapi:
    ...
    +  serializer.normalizer.content_entity.jsonapi:
    
    @@ -75,8 +75,13 @@ services:
    -    class: Drupal\jsonapi\Normalizer\ConfigEntityNormalizer
    ...
    +    class: Drupal\jsonapi\Normalizer\ConfigEntityDenormalizer
    ...
    +  serializer.normalizer.resource_object.jsonapi:
    

    Now, we only use entity normalizers for denormalization. Normalization is applied to ResourceObjects instead of entities.

    This is a small step in the direction of getting rid of jsonapi_normalizer_do_not_use_removal_imminent because it's ResourceObjects are a value object in our own namespace. We don't need to worry about conflicts with REST normalizers.

  2. +++ b/src/Controller/EntityResource.php
    @@ -213,24 +214,19 @@ class EntityResource {
    -      if (isset($document['data']['attributes'])) {
    ...
    -      if (isset($document['data']['relationships'])) {
    ...
    +      foreach (['attributes', 'relationships'] as $data_member_name) {
    +        if (isset($document['data'][$data_member_name])) {
    +          $valid_names = array_filter(array_map(function ($public_field_name) use ($resource_type) {
    +            return $resource_type->getInternalName($public_field_name);
    +          }, array_keys($document['data'][$data_member_name])), function ($internal_field_name) use ($resource_type) {
    +            return $resource_type->hasField($internal_field_name);
    +          });
    

    This change just gets rid of the two similar loops.

    The only material change is the introduction of array_filter($resource_type->hasField(...)).

  3. +++ b/src/Controller/EntityResource.php
    @@ -1030,13 +1029,15 @@ class EntityResource {
    -  public static function getAccessCheckedEntity(EntityInterface $entity) {
    ...
    +  public static function getAccessCheckedResourceObject(EntityInterface $entity) {
    ...
    +    $resource_type_repository = \Drupal::service('jsonapi.resource_type.repository');
    
    @@ -1052,8 +1053,8 @@ class EntityResource {
    -    return $entity;
    +    $resource_type = $resource_type_repository->get($entity->getEntityTypeId(), $entity->bundle());
    +    return new ResourceObject($resource_type, $entity);
    

    I would have liked to inject this, but this is a static method. The revisions patch extract this method into a service, so this will soon go away.

  4. +++ b/src/IncludeResolver.php
    @@ -39,7 +41,7 @@ class IncludeResolver {
    +   * @param \Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\EntityCollection $data
    

    This should be ResourceIdentifierInterface.

  5. +++ b/src/IncludeResolver.php
    @@ -100,21 +103,26 @@ class IncludeResolver {
    +        // @todo: remove this and change IncludeResolver::resolveInternalIncludePath to *not* return internal field names. ResourceObjects make that unnecessary.
    

    Remove this @todo or make a followup issue for it.

  6. +++ b/src/JsonApiResource/EntityCollection.php
    @@ -53,17 +51,15 @@ class EntityCollection implements \IteratorAggregate, \Countable {
    -        || $entity instanceof EntityInterface
    -        || $entity instanceof LabelOnlyEntity
    -        || $entity instanceof EntityAccessDeniedHttpException;
    -    }, $resources));
    +        || $entity instanceof ResourceIdentifierInterface;
    

    Now all the objects in an EntityCollection implement ResourceIdentifierInterface, which leads to some simplifications elsewhere and will hopefully lead to more in the future.

  7. +++ b/src/JsonApiResource/EntityCollection.php
    @@ -171,15 +167,9 @@ class EntityCollection implements \IteratorAggregate, \Countable {
    -      if ($resource instanceof EntityInterface) {
    -        $resource_identifier = ResourceIdentifier::fromEntity($resource);
    -        $dedupe_key = $resource_identifier->getTypeName() . ':' . $resource_identifier->getId();
    -      }
    -      else {
    -        $dedupe_key = $resource->getTypeName() . ':' . $resource->getId();
    -        if ($resource instanceof EntityAccessDeniedHttpException && ($error = $resource->getError()) && !is_null($error['relationship_field'])) {
    -          $dedupe_key .= ':' . $error['relationship_field'];
    -        }
    +      $dedupe_key = $resource->getTypeName() . ':' . $resource->getId();
    +      if ($resource instanceof EntityAccessDeniedHttpException && ($error = $resource->getError()) && !is_null($error['relationship_field'])) {
    +        $dedupe_key .= ':' . $error['relationship_field'];
    

    Here is one such simplification. No special exception is needed for entities.

  8. +++ b/src/JsonApiResource/ResourceIdentifier.php
    @@ -64,6 +64,15 @@ class ResourceIdentifier implements ResourceIdentifierInterface {
    +  public function getResourceType() {
    +    /* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository */
    +    $resource_type_repository = \Drupal::service('jsonapi.resource_type.repository');
    +    return $resource_type_repository->getByTypeName($this->getTypeName());
    +  }
    
    +++ b/src/JsonApiResource/ResourceObject.php
    @@ -0,0 +1,199 @@
    +  public function getResourceType() {
    +    return $this->resourceType;
    +  }
    

    The second method can be removed.

  9. +++ b/src/JsonApiResource/ResourceIdentifierTrait.php
    @@ -30,4 +39,16 @@ trait ResourceIdentifierTrait {
    +    if (!isset($this->resourceType)) {
    +      /* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository */
    +      $resource_type_repository = \Drupal::service('jsonapi.resource_type.repository');
    +      $this->resourceType = $resource_type_repository->getByTypeName($this->getTypeName());
    ...
    +    return $this->resourceType;
    

    This should be moved to ResourceIdentifier

  10. +++ b/src/JsonApiResource/ResourceObject.php
    @@ -0,0 +1,199 @@
    + * Represent a JSON API resource object.
    ...
    + * This value object wraps a Drupal entity so that it can carry a JSON API
    

    s/JSON API/JSON:API

  11. +++ b/src/JsonApiResource/ResourceObject.php
    @@ -0,0 +1,199 @@
    +    $this->resourceIdentifier = new ResourceIdentifier($this->resourceType->getTypeName(), $this->entity->uuid());
    

    ResourceIdentifier::__construct() should be able to take a ResourceType object or string as its first argument. That would eliminate some requests to the resource type repository.

  12. +++ b/src/JsonApiResource/ResourceObject.php
    @@ -0,0 +1,199 @@
    +    return $entity instanceof ContentEntityInterface
    +      ? $this->extractContentEntityFields($entity)
    +      : $this->extractConfigEntityFields($entity);
    ...
    +  protected function extractContentEntityFields(ContentEntityInterface $entity) {
    ...
    +  protected function extractConfigEntityFields(ConfigEntityInterface $entity) {
    

    All this logic is simply moved over from the now removed entity-level normalize methods.

  13. +++ b/src/Normalizer/EntityDenormalizerBase.php
    @@ -0,0 +1,122 @@
    + * Converts the Drupal entity object to a JSON API array structure.
    

    s/JSON API/JSON:API

  14. +++ b/src/Normalizer/EntityReferenceFieldNormalizer.php
    @@ -34,13 +36,10 @@ class EntityReferenceFieldNormalizer extends FieldNormalizer {
    -  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, EntityRepositoryInterface $entity_repository) {
    -    $this->resourceTypeRepository = $resource_type_repository;
    +  public function __construct(EntityRepositoryInterface $entity_repository) {
    

    Another place we can eliminate some requests to the repository.

  15. +++ b/src/Normalizer/LabelOnlyEntityNormalizer.php
    @@ -60,11 +61,13 @@ class LabelOnlyEntityNormalizer extends NormalizerBase {
       public function normalize($label_only_entity, $format = NULL, array $context = []) {
    ...
    -    $context['resource_type'] = $this->resourceTypeRepository->get(
    +    $resource_type = $this->resourceTypeRepository->get(
    ...
    +    $resource_object = new ResourceObject($resource_type, $entity);
    

    This use of the repository can go away too.

    Actually, LabelOnlyEntityNormalizer can
    go away altogether if LabelOnlyEntity just extends ResourceObject and overrides extractFields!

  16. +++ b/src/Normalizer/RelationshipItem.php
    @@ -51,11 +50,9 @@ class RelationshipItem {
    -   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
    -   *   The JSON:API resource type repository.
    
    @@ -63,23 +60,19 @@ class RelationshipItem {
    -      $relatable_resource_types = $resource_type_repository->get(
    -        $host_entity->getEntityTypeId(),
    -        $host_entity->bundle()
    -      )->getRelatableResourceTypes()[$parent->getPropertyName()];
    

    More repository removal.

gabesullice’s picture

Status: Active » Needs review
StatusFileSize
new20.15 KB
new20.15 KB
new94.14 KB
new96.57 KB

All my self review things in #12 have been addressed.

I'm including two patches:

  1. one is #12 + the interdiff
  2. the other "smaller" patch is generated with git diff -M2%; I'm hoping this will make it easier to review. It didn't.
gabesullice’s picture

Just ignore the "smaller" patch in #14, it's not easier to review.

The last submitted patch, 14: 3015438-13.smaller.patch, failed testing. View results

Status: Needs review » Needs work

The last submitted patch, 14: 3015438-13.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new3.33 KB
new97.3 KB

Status: Needs review » Needs work

The last submitted patch, 18: 3015438-18.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new3.15 KB
new96.49 KB

The approach in #18 didn't really work for config entities. I found this simpler way.

gabesullice’s picture

Title: [WIP] Wrap entity objects in a ResourceObject which carries a ResourceType » Wrap entity objects in a ResourceObject which carries a ResourceType
Assigned: gabesullice » Unassigned
StatusFileSize
new11.18 KB
new107.67 KB

And the most fun interdiff of them all... I can remove EntityNormalizer & ContentEntityNormalizer now for a grand total of 297 deletions!

wim leers’s picture

This is a small step in the direction of getting rid of jsonapi_normalizer_do_not_use_removal_imminent because

🎶 Music to my ears! Especially since people have been making remarks about that on the core issue

eliminate some requests to the repository.

👍

Sneaky little label callbackses...

😀

grand total of 297 deletions!

🎉


Actual patch review:

  1. +++ b/jsonapi.services.yml
    @@ -64,19 +64,19 @@ services:
    +    class: Drupal\jsonapi\Normalizer\ContentEntityDenormalizer
    ...
    +    class: Drupal\jsonapi\Normalizer\ConfigEntityDenormalizer
    ...
    +    class: Drupal\jsonapi\Normalizer\ResourceObjectNormalizer
    

    This means we're now normalizing all "resource objects" in JSON:API terminology in exactly the same way.

    Denormalization is different out of necessity.

    Makes me curious about the rest of the patch :)

  2. +++ b/src/Controller/EntityResource.php
    @@ -213,24 +214,19 @@ class EntityResource {
    -      if (isset($document['data']['attributes'])) {
    ...
    -      if (isset($document['data']['relationships'])) {
    ...
    +      foreach (['attributes', 'relationships'] as $data_member_name) {
    

    I think this simplification could already happen in a separate minor issue.

    Let's keep it here for now. If there's many more of these simplifications, lets' spin it out into a separate issue.

  3. +++ b/src/Controller/EntityResource.php
    @@ -1030,30 +1029,32 @@ class EntityResource {
    -   * @return \Drupal\Core\Entity\EntityInterface|\Drupal\jsonapi\LabelOnlyEntity|\Drupal\jsonapi\Exception\EntityAccessDeniedHttpException
    +   * @return \Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\LabelOnlyEntity|\Drupal\jsonapi\Exception\EntityAccessDeniedHttpException
        *   The loaded entity, a label only version of that entity or an
    

    Übernit: this comment needs to be updated.

  4. +++ b/src/Controller/EntityResource.php
    @@ -1030,30 +1029,32 @@ class EntityResource {
    -        return new LabelOnlyEntity($entity);
    +        return new LabelOnlyEntity($resource_type, $entity);
    

    If we're changing the parameters, shouldn't this become LabelOnlyResourceObject?

  5. +++ b/src/IncludeResolver.php
    @@ -100,21 +103,25 @@ class IncludeResolver {
    -        if (!$entity->hasField($field_name)) {
    +        if (!$entity->hasField($public_field_name)) {
    

    The old line was correct, this only needs to change because $entity is no longer an EntityInterface instance. I think it may be less confusing to just rename it to $resource_object … but I'm guessing you didn't do that to minimize change?

  6. +++ b/src/IncludeResolver.php
    @@ -100,21 +103,25 @@ class IncludeResolver {
    -        // @todo: raise an omitted item to an inaccessible related field in https://www.drupal.org/project/jsonapi/issues/2956084.
    

    Why can we remove this TODO?

  7. +++ b/src/JsonApiResource/EntityCollection.php
    @@ -15,11 +13,11 @@ use Drupal\jsonapi\LabelOnlyEntity;
     class EntityCollection implements \IteratorAggregate, \Countable {
    

    Not yet renamed to ResourceObjectCollection to minimize change probably.

    (Could happen in a separate issue.)

  8. +++ b/src/JsonApiResource/ResourceObject.php
    @@ -0,0 +1,189 @@
    +class ResourceObject implements CacheableDependencyInterface, ResourceIdentifierInterface {
    

    😍

  9. +++ b/src/JsonApiResource/ResourceObject.php
    @@ -0,0 +1,189 @@
    +  use CacheableDependencyTrait;
    +  use ResourceIdentifierTrait;
    

    😍

  10. +++ b/src/JsonApiResource/ResourceObject.php
    @@ -0,0 +1,189 @@
    +  /**
    +   * The resource type of this resource object.
    +   *
    +   * @var array
    +   */
    +  protected $fields;
    

    Comment is wrong.

  11. +++ b/src/JsonApiResource/ResourceObject.php
    @@ -0,0 +1,189 @@
    +   *   The underlying entity.
    

    Nit: what about "decorated"?

  12. +++ b/src/JsonApiResource/ResourceObject.php
    @@ -0,0 +1,189 @@
    +   * Gets the ResourceObject's fields.
    +   *
    +   * @return mixed|\Drupal\Core\Field\FieldItemListInterface[]
    +   *   The resource object's fields.
    

    Some doubts about exposing a Drupal-specific data structure when this object is all about decorating everything in JSON:API terminology. But I can't think of a better alternative.

  13. +++ b/src/JsonApiResource/ResourceObject.php
    @@ -0,0 +1,189 @@
    +  protected function extractFields(EntityInterface $entity) {
    +    assert($entity instanceof ContentEntityInterface || $entity instanceof ConfigEntityInterface);
    +    return $entity instanceof ContentEntityInterface
    +      ? $this->extractContentEntityFields($entity)
    +      : $this->extractConfigEntityFields($entity);
    +  }
    

    👌

  14. +++ b/src/LabelOnlyEntity.php
    @@ -2,34 +2,15 @@
    +final class LabelOnlyEntity extends ResourceObject {
    
    @@ -41,6 +22,17 @@ class LabelOnlyEntity implements CacheableDependencyInterface, ResourceIdentifie
    +  protected function extractFields(EntityInterface $entity) {
    +    $fields = parent::extractFields($entity);
    

    😍

  15. +++ b/src/Normalizer/EntityDenormalizerBase.php
    @@ -0,0 +1,122 @@
    +  public function normalize($object, $format = NULL, array $context = []) {
    +    assert(FALSE);
    +  }
    

    I'd even throw a \LogicException I think.

  16. +++ b/src/Normalizer/EntityDenormalizerBase.php
    @@ -0,0 +1,122 @@
    +  abstract protected function prepareInput(array $data, ResourceType $resource_type, $format, array $context);
    

    👍

  17. +++ b/src/Normalizer/EntityReferenceFieldNormalizer.php
    @@ -87,7 +86,7 @@ class EntityReferenceFieldNormalizer extends FieldNormalizer {
    -          $entity_list[] = FALSE;
    +          $resource_objects[] = FALSE;
               $entity_list_metadata[] = [
    

    I don't think it makes sense to rename $entity_list if we're not renaming so many other things, and especially when there's the closely associated/in sync $entity_list_metadata.

  18. +++ b/src/Normalizer/EntityReferenceFieldNormalizer.php
    @@ -123,34 +122,42 @@ class EntityReferenceFieldNormalizer extends FieldNormalizer {
    +    $relationship = new Relationship($field->getName(), $entity_collection, $context['resource_object'], $cacheabilty, $cardinality, $main_property, $entity_list_metadata);
    
    +++ b/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
    @@ -193,6 +194,7 @@ class JsonApiDocumentTopLevelNormalizer extends NormalizerBase implements Denorm
    +      $context['resource_object'] = new ResourceObject($context['resource_type'], $data->getEntity());
    

    This is why we're suddenly using $context for crucial things, right?

    If we're to do this, then let's at least immediately unset() it after it's fulfilled its purpose. We want to prevent unintentional dependencies on this context.

  19. +++ b/src/Normalizer/EntityReferenceFieldNormalizer.php
    @@ -123,34 +122,42 @@ class EntityReferenceFieldNormalizer extends FieldNormalizer {
    +    // TODO: We are always loading the referenced entity. Even if it is not
    +    // going to be included. That may be a performance issue. We do it because
    
    +++ b/src/Normalizer/RelationshipItemNormalizer.php
    @@ -42,11 +42,6 @@ class RelationshipItemNormalizer extends FieldItemNormalizer {
    -    // TODO: We are always loading the referenced entity. Even if it is not
    -    // going to be included. That may be a performance issue. We do it because
    

    That is not a regression compared to HEAD though. Optimizing this deeply, based on the requested sparse fieldset is not only going to be tricky and perhaps pretty complex, it would also conflict with #2819335-18: Resource (entity) normalization should use partial caching. So I think this TODO can be removed!

    Ah, I see, I mis understood it. Why move this?

  20. +++ /dev/null
    @@ -1,91 +0,0 @@
    -class LabelOnlyEntityNormalizer extends NormalizerBase {
    

    🤘

  21. +++ b/src/Normalizer/Relationship.php
    @@ -45,13 +44,6 @@ class Relationship implements AccessibleInterface, CacheableDependencyInterface
    -  protected $resourceTypeRepository;
    
    @@ -80,19 +70,17 @@ class Relationship implements AccessibleInterface, CacheableDependencyInterface
           $this->items[] = new RelationshipItem(
    -        $resource_type_repository,
    

    Nice!

  22. +++ b/src/Normalizer/Relationship.php
    @@ -62,13 +54,11 @@ class Relationship implements AccessibleInterface, CacheableDependencyInterface
    +   * @param \Drupal\jsonapi\JsonApiResource\ResourceObject $host_resource_object
        *   The host entity.
    

    Docs nit.

  23. +++ b/src/Normalizer/Relationship.php
    @@ -113,21 +101,11 @@ class Relationship implements AccessibleInterface, CacheableDependencyInterface
    -  public function setHostEntity(EntityInterface $hostEntity) {
    

    🤘 That means this is now an immutable value object, great!

    Looks like this actually can also be done already in HEAD.

  24. +++ b/src/Normalizer/RelationshipItem.php
    @@ -92,13 +85,10 @@ class RelationshipItem {
    -      $this->targetResourceType = $resource_type_repository->get(
    -        $target_entity->getEntityTypeId(),
    -        $target_entity->bundle()
    -      );
    +      $this->targetResourceType = $target_resource_object->getResourceType();
    

    🤘

  25. +++ b/src/Normalizer/ResourceObjectNormalizer.php
    @@ -0,0 +1,141 @@
    +   * The interface or class that this Normalizer supports.
    +   *
    +   * @var string
    ...
    +   * The formats that the Normalizer can handle.
    +   *
    +   * @var array
    

    Nit: inheritdoc.

  26. +++ b/src/Normalizer/Value/ResourceObjectNormalizerValue.php
    @@ -51,20 +51,20 @@ class EntityNormalizerValue implements ValueExtractorInterface, CacheableDepende
         $this->context = $context;
    

    I believe that $this->context was made obsolete here? :)

wim leers’s picture

I forgot two things:

  1. I'd like to get a better idea of how #3014277: ResourceTypes should know about their fields and this issue work together.
  2. +++ b/jsonapi.services.yml
    @@ -64,19 +64,19 @@ services:
    -    class: Drupal\jsonapi\Normalizer\ContentEntityNormalizer
    +    class: Drupal\jsonapi\Normalizer\ContentEntityDenormalizer
    ...
    -    class: Drupal\jsonapi\Normalizer\LabelOnlyEntityNormalizer
    +    class: Drupal\jsonapi\Normalizer\ConfigEntityDenormalizer
    ...
    -    class: Drupal\jsonapi\Normalizer\ConfigEntityNormalizer
    +    class: Drupal\jsonapi\Normalizer\ResourceObjectNormalizer
    

    This will break JSON:API Extras.

gabesullice’s picture

StatusFileSize
new15.63 KB
new111.74 KB

1. 👍
2. Agreed.
3. Done.
4. Yes, I left it to keep the patch size down. But now that you've reviewed and a generally positive about it, I'll make the change.
5. You're correct. As in #4, I'll make that change now.
6. It's a leftover, #2956084: Impossible to raise an error when an `include` is requested for an inaccessible relationship field. is Closed (fixed).
7. Why don't we keep it in this issue but make it the last thing we do, so that you can just review a final interdiff?
8/9. :D
10. Fixed.
11. I went with "The entity to be represented by this resource object." Decoration adds behavior, a ResourceObject actually strips away most behaviors.
12. I too sniffed the air because this was a little fishy, but technically "a resource object’s attributes and its relationships are collectively called its 'fields'". Good enough for now I think.
13/14. :D
15. Done.
16. :)
17. I renamed this to $relationship_object_metadata to indicate where in the response document that information will end up.
18. Done.
19. Because in its previous location, it didn't make any sense because we weren't loading any entities. I moved it to the spot where we'er actually loading an entity. I think perhaps the code that it referenced was moved or refactored and the comment wasn't moved with it.
20/21. :)
22. Fixed.
23/24. :)
25. Good catch.
26. Yep, 🦅👁

#23:
1. Me too :P
2. Yes. I'll take a look into how/why

gabesullice’s picture

StatusFileSize
new1.29 KB
new112.12 KB

#23.1: I posted a review here: #3014277-15: ResourceTypes should know about their fields. I was pleasantly surprised to find that these two patches will not interact much at all. Since it doesn't change the API of the ResourceType object, this patch can remain unchanged.

#23.2: Extras is overriding ConfigEntityNormalizer and ContentEntityInterface so that it can apply its field enhancers. The most complicated changes are surrounding denormalizers. But we're only renaming classes for that use case, meaning it will just mean renaming some classes for Extras.

The one other change to account for is that Extra's ConfigEntityNormalizer overrides the getFields method so that it can apply its field enhancers on normalization. In this patch, I removed that method because it was moved onto the ResourceObject class. To make it easy for extras, I'll just restore that method in a simpler form so we can preserve Extras' behavior.

The last thing to be concerned with is that Extras only does this "field" enhancement in the config entity normalizer (content entity field enhancement happens in FieldItemNormalizer). In this patch, normalization of config and content entities are handle by the same class. That's an easy problem to fix by performing an !$field instanceof FieldItemListInterface check in getFields thus skipping the enhancement for content entities.

gabesullice’s picture

Title: Wrap entity objects in a ResourceObject which carries a ResourceType » [PP-1] Wrap entity objects in a ResourceObject which carries a ResourceType
Status: Needs review » Postponed
Related issues: +#3022584: Consolidate and simplify NormalizerValue objects: introduce CacheableNormalization
wim leers’s picture

Title: [PP-1] Wrap entity objects in a ResourceObject which carries a ResourceType » Wrap entity objects in a ResourceObject which carries a ResourceType
Status: Postponed » Needs work
gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new108.78 KB

"Rerolled" is an understatement :P but that's what I did.

Status: Needs review » Needs work

The last submitted patch, 28: 3015438-28.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new2.7 KB
new110.27 KB

Looks like I missed a small update in FileUploadTest and accidentally triggered a deprecation notice because of a private service. NBD.

Status: Needs review » Needs work

The last submitted patch, 30: 3015438-30.patch, failed testing. View results

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new1018 bytes
new110.39 KB

Oh PHP… 😭

wim leers’s picture

I don't have time to do a deep review of this today. But ideally the issue summary would explain what work this unblocks.

It's not in the issue summary, but #3.7 is citing improved performance as an important reason for doing that. But based on preliminary benchmarking (admittedly with only 4 nodes), I'm seeing a slowdown from ~87 ms for 100 requests to ~90 ms (ab -c 1 -n 100 -H "Accept: application/vnd.api+json" http://d8/jsonapi/node/article, with Page Cache & Dynamic Page Cache disabled.)

It looks like this does not block #3014277: ResourceTypes should know about their fields.

I'm sure I'm missing something, if you can just add a list of reasons why we should do this (benefits/unblocking) to the issue summary, that'd definitely help :)

gabesullice’s picture

Issue summary: View changes

It's not in the issue summary, but #3.7 is citing improved performance as an important reason for doing that.

See #6 and #7 ;)

I updated the issue summary with some other callouts.

gabesullice’s picture

Issue summary: View changes
gabesullice’s picture

StatusFileSize
new110.21 KB

Reroll.

Status: Needs review » Needs work

The last submitted patch, 36: 3015438-36.patch, failed testing. View results

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new525 bytes
new110.45 KB

Status: Needs review » Needs work

The last submitted patch, 38: 3015438-38.patch, failed testing. View results

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new893 bytes
new110.5 KB

#36 lost the fix from #32.

wim leers’s picture

StatusFileSize
new11.79 KB
new109.6 KB
  1. With this, we can pass links into ResourceObjects prior to normalization too

    💪 Let's update the patch to do this. So that we can see the full set of benefits that this brings us. But let's first address what's below.

  2. +++ b/src/Controller/EntityResource.php
    @@ -482,7 +478,10 @@ class EntityResource {
       public function getRelationship(ResourceType $resource_type, FieldableEntityInterface $entity, $related, Request $request, $response_code = 200) {
    ...
    +    // Access will have already been checked by the RelationshipFieldAccess
    +    // service, so we don't need to call ::getAccessCheckedResourceObject().
    

    👍 So this is only ever called if \Drupal\jsonapi\Access\RelationshipFieldAccess::access() granted access.

    This is just adding documentation to help future readers.

  3. +++ b/src/JsonApiResource/EntityCollection.php
    @@ -15,11 +13,11 @@ use Drupal\jsonapi\LabelOnlyEntity;
    +   * @var \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface[]
    
    @@ -45,7 +43,7 @@ class EntityCollection implements \IteratorAggregate, \Countable {
    +   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface[]|null[]|false[] $resource_objects
    
    @@ -53,17 +51,15 @@ class EntityCollection implements \IteratorAggregate, \Countable {
    +        || $entity instanceof ResourceIdentifierInterface;
    

    🤔 Hm … but I don't think we allow or ever intend to allow class ResourceIdentifier implements ResourceIdentifierInterface, do we?

    Why can't we change this to \Drupal\jsonapi\JsonApiResource\ResourceObject?

  4. +++ b/src/JsonApiResource/JsonApiDocumentTopLevel.php
    @@ -44,7 +46,7 @@ class JsonApiDocumentTopLevel {
    +   * @param \Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\EntityCollection|\Drupal\jsonapi\LabelOnlyResourceObject|\Drupal\jsonapi\JsonApiResource\ErrorCollection $data
    

    🤔 I don't think we need to list LabelOnlyResourceObject here, since it subclasses ResourceObject. Previously, LabelOnlyEntity did not implement EntityInterface, so it had to be listed separately.

    That makes us get quite close to an ideal state, because it means only 3 kinds of things are allowed: individual resource objects, collections of resource objects (for now still called EntityCollection, but we'll soon be able to rename that to ResourceObjectCollection) or a collection of errors.

  5. +++ b/src/JsonApiResource/JsonApiDocumentTopLevel.php
    @@ -56,6 +58,7 @@ class JsonApiDocumentTopLevel {
    +    assert($data instanceof ResourceIdentifierInterface || $data instanceof EntityCollection || $data instanceof ErrorCollection || $data instanceof EntityReferenceFieldItemListInterface);
    

    🤔 This … sort of does what I was getting at above: it omits LabelOnlyResourceObject. But curiously, it checks ResourceIdentifierInterface, not ResourceObject.

    And apparently one potential type was missing from the docblock? Because we're also allowing

    EntityReferenceFieldItemListInterface</code. Remind me when that's necessary again?
    </li>
    
    <li>
    <code>
    +++ b/src/JsonApiResource/ResourceIdentifier.php
    @@ -40,18 +48,22 @@ class ResourceIdentifier implements ResourceIdentifierInterface {
    +   * @param \Drupal\jsonapi\ResourceType\ResourceType|string $resource_type
    +   *   The JSON:API resource type or a JSON:API resource type name.
    ...
    +    $this->resourceTypeName = is_string($resource_type) ? $resource_type : $resource_type->getTypeName();
    ...
    +    if (!is_string($resource_type)) {
    +      $this->resourceType = $resource_type;
    +    }
    
    @@ -65,6 +77,18 @@ class ResourceIdentifier implements ResourceIdentifierInterface {
    +  public function getResourceType() {
    +    if (!isset($this->resourceType)) {
    

    🤔 Remind me why we allow both a ResourceTypeobject and string, and retrieve the object when necessary? Why not always require it to be passed in?

    You don't need to answer that on the issue, but documenting the rationale and its assumptions in this class allow us to more easily make this simpler in the future.

  6. +++ b/src/JsonApiResource/ResourceObject.php
    @@ -0,0 +1,189 @@
    + * This value object wraps a Drupal entity so that it can carry a JSON:API
    + * resource type object alongside it.
    

    🐛 That's one purpose. Another purpose is to be able to remove the need to distinguish between config and content entities all over the JSON:API code base. Let's document that too.

  7. +++ b/src/JsonApiResource/ResourceObject.php
    @@ -0,0 +1,189 @@
    +   * The fields of the represented entity.
    ...
    +   * @var array
    

    🐛 Just array means I still need to guess whether this is field names (string[]) or field instances (FieldItemListInterface[]).

  8. +++ b/src/LabelOnlyResourceObject.php
    @@ -0,0 +1,48 @@
    +  public function getEntity() {
    +    return $this->entity;
    +  }
    

    🤔 Hm … why does this live on this class and not on ResourceObject? Looks like this is only for the label only case in \Drupal\jsonapi\IncludeResolver::resolveIncludeTree().

    I think this makes sense for now, but I suspect we'll be able to remove it when we refactor EntityAccessDeniedHttpException to ResourceObjectAccessDeniedHttpException?

  9. +++ b/src/LabelOnlyResourceObject.php
    @@ -0,0 +1,48 @@
    +    return $label_field_name && $this->resourceType->isFieldEnabled($label_field_name) ?
    +      [$this->resourceType->getPublicName($label_field_name) => $fields[$label_field_name]]
    +      : [];
    

    ✅ Can't this be simplified to an
    array_intersect_key() call? We're repeating logic that the parent method already did. Done.

  10. +++ b/src/LabelOnlyResourceObject.php
    @@ -0,0 +1,48 @@
    +  /**
    +   * Determines the entity type's (internal) label field name.
    +   */
    +  public function getLabelFieldName() {
    +    $label_field_name = $this->entity->getEntityType()->getKey('label');
    +    // @todo Remove this work-around after https://www.drupal.org/project/drupal/issues/2450793 lands.
    +    if ($this->entity->getEntityTypeId() === 'user') {
    +      $label_field_name = 'name';
    +    }
    +    return $label_field_name;
    +  }
    

    ✅ This is also duplicating logic from the parent class. It'd be better to move this instead then. Also: this should not have been public. Done.

  11. +++ b/src/Normalizer/ConfigEntityDenormalizer.php
    @@ -0,0 +1,33 @@
    +   * The interface or class that this Normalizer supports.
    

    ✅ Nit: Can just be {@inheritdoc}. Fixed.

  12. +++ b/src/Normalizer/ContentEntityDenormalizer.php
    @@ -0,0 +1,81 @@
    +         ));
    

    ✅ Übernit: indentation is one space off. Fixed.

  13. +++ b/src/Normalizer/EntityDenormalizerBase.php
    @@ -0,0 +1,122 @@
    +  /**
    +   * The formats that the Normalizer can handle.
    +   *
    +   * @var array
    +   */
    +  protected $formats = ['api_json'];
    

    ✅ This is already inherited from the base class, can be removed. Done.

  14. +++ b/src/Normalizer/EntityDenormalizerBase.php
    @@ -0,0 +1,122 @@
    +   * Constructs an EntityNormalizer object.
    

    ✅ Nit: mismatch. Fixed.

  15. +++ b/src/Normalizer/EntityReferenceFieldNormalizer.php
    @@ -66,8 +75,8 @@ class EntityReferenceFieldNormalizer extends FieldNormalizer {
    -          $entity_list[] = NULL;
    -          $entity_list_metadata[] = [
    +          $resource_objects[] = NULL;
    +          $relationship_object_metadata[] = [
    

    🤔 This used to be clearly paired before. I mentioned this before in #22.17. You addressed it. But I think this is still fairly confusing. The array indices need to be kept in sync.
    Perhaps #3024896: Remove Relationship and RelationshipItem classes will simplify this?

  16. +++ b/src/Normalizer/Relationship.php
    @@ -36,7 +35,7 @@ class Relationship implements AccessibleInterface, CacheableDependencyInterface
        * @var \Drupal\Core\Entity\EntityInterface
    
    @@ -113,21 +101,11 @@ class Relationship implements AccessibleInterface, CacheableDependencyInterface
        * Gets the host entity.
    
    +++ b/src/Normalizer/RelationshipItem.php
    @@ -23,9 +22,9 @@ class RelationshipItem {
        * The target entity.
    

    ✅ Nit: entity -> resource object leftovers. Fixed.

  17. +++ b/src/Normalizer/RelationshipItem.php
    @@ -23,9 +22,9 @@ class RelationshipItem {
    +   * @var \Drupal\jsonapi\JsonApiResource\ResourceObject|null|false
    
    @@ -51,11 +50,9 @@ class RelationshipItem {
    +   * @param \Drupal\Core\Entity\EntityInterface|null|false $target_resource_object
    
    @@ -63,23 +60,19 @@ class RelationshipItem {
    +    assert($target_resource_object === NULL || $target_resource_object === FALSE || $target_resource_object instanceof ResourceIdentifierInterface);
    

    🐛 Mismatch: ResourceObject vs ResourceIdentifierInterface vs EntityInterface.

  18. +++ b/src/Normalizer/RelationshipNormalizer.php
    @@ -168,10 +169,10 @@ class RelationshipNormalizer extends NormalizerBase implements DenormalizerInter
    -    assert($context['resource_type'] instanceof ResourceType);
    -    $resource_type = $context['resource_type'];
    +    assert($context['resource_object'] instanceof ResourceObject);
    +    $resource_type = $context['resource_object']->getResourceType();
    

    🤔 I'm not sure I understand the value of this change?

  19. +++ b/src/Normalizer/ResourceObjectNormalizer.php
    @@ -0,0 +1,162 @@
    +  /**
    +   * {@inheritdoc}
    +   */
    +  protected $formats = ['api_json'];
    

    ✅ This is already inherited, can be removed. Done.

  20. +++ b/src/Normalizer/ResourceObjectNormalizer.php
    @@ -0,0 +1,162 @@
    +   * Constructs an EntityNormalizer object.
    

    ✅ Mismatch. Fixed.

  21. +++ b/src/Normalizer/ResourceObjectNormalizer.php
    @@ -0,0 +1,162 @@
    +  protected function getFields(ResourceObject $object) {
    ...
    +  protected function getFieldNames(ResourceObject $object) {
    

    These can now be static. Done. Actually, having these helper methods is pointless now, thanks to ResourceObject! Removed.

  22. +++ b/src/Normalizer/ResourceObjectNormalizer.php
    @@ -0,0 +1,162 @@
    +    /* @var \Drupal\Core\Entity\ContentEntityInterface $entity */
    

    ✅ Needed updating. Fixed.

In doing this, I made the patch slightly smaller :) Now the diffstat is: 869 insertions(+), 866 deletions(-). So: same LoC, but clearer code.

wim leers’s picture

StatusFileSize
new104.38 KB

Also: if you roll the patch with -M5% it will record the moves, making the patch not only a few K smaller, but also easier to understand. (659 insertions(+), 656 deletions(-))

The last submitted patch, 41: 3015438-41.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

wim leers’s picture

Assigned: Unassigned » gabesullice
Status: Needs review » Needs work
StatusFileSize
new684 bytes
new104.38 KB

Yay, #41 is still green, but it contains a single CS violation 😬 Easy fix.

Assigning to Gabe for points 1, 3–8, 15, 18 in #41

wim leers’s picture

<off-topic>

Idea for follow-up issue: add ::toUrl() method inspired by EntityInterface::toUrl(). This would remove the need for \Drupal\jsonapi\LinkManager\LinkManager::getEntityLink(): you'd do ResourceObject::toUrl('individual') instead. Or even ::toLink(), which would return a \Drupal\jsonapi\JsonApiResource\Link().

</off-topic>

(Shall I create a follow-up issue for that?)

gabesullice’s picture

Assigned: gabesullice » Unassigned
Status: Needs work » Needs review
StatusFileSize
new5.08 KB
new106.42 KB

#41:

1. Will do in another comment.
3. EntityAccessDeniedException implements ResourceIdentifierInterface though, so an interface is useful here. It means that none of the logic needs to know that a resource object is interchangeable with an exception.

I actually do imagine that one day we might pass in ResourceIdentifier objects. An EntityCollection essentially represents a document's "primary data" and the primary data for a relationship response could easily be a collection of ResourceIdentifiers after #3024896: Remove Relationship and RelationshipItem classes. A followup to this issue could be to rename EntityCollection to PrimaryData and for individual responses not accept a ResourceObject but PrimaryData (formerly, EntityCollection) with a cardinality of 1 instead. That would make individual documents indistinguishable from unary `related` documents except for their links.

Given that this is all internal, I think it's fine to have this small bit of imprecision (hopefully temporary).

4. Removed LabelOnlyResourceObject. About the ideal state, see above.
5. For the most part, see #3 above. WRT to EntityReferenceFieldItemListInterface, that's what we pass in from EntityResource::getRelationship (ofc, that's also related to what I said above!)
6. Honestly, I never even realized that myself. Good point!
7. Clarified.
8. Exactly.
15. Yep, it already did: #3024896-15: Remove Relationship and RelationshipItem classes
18. Whoops! I meant to take this concept further and forgot about it. Because resource type is carried by a resource object, there's no longer a need for a single request level resource type during normalization (see interdiff). This was a stumbling block here. With this code, a relationship normalizer "knows" about the parent resource object instead of the the parent resource type and that just feels more "right".

Tying this back into #3, I think this helps us conceptually unify normalizing a relationship's primary data and normalizing a resource object's relationship object. Why would that be cool? Beyond fewer concepts... partial caching. Normalizing either a resource object or a relationship object could help warm the cache for the other. That's really useful because normalizing relationship objects requires that we load all the referenced entities in a really inefficient way:

// TODO: We are always loading the referenced entity. Even if it is not
// going to be included. That may be a performance issue. We do it because
// we need to know the entity type and bundle to load the JSON:API resource
// type for the relationship item and we need to know the UUID, which is not
// stored on the entity reference item. We need a better way of finding out
// about this.
$entity = $item->get('entity')->getValue()

and this:

// A non-empty entity reference field that refers to a non-existent entity
// is not a data integrity problem. For example, Term entities' "parent"
// entity reference field uses target_id zero to refer to the non-existent
// "<root>" term. And references to entities that no longer exist are not
// cleaned up by Drupal; hence we map it to a "missing" resource.
if ($item->get('entity')->getValue() === NULL) {

Also: if you roll the patch with -M5% it will record the moves, making the patch not only a few K smaller, but also easier to understand.

You've mentioned this before and I did it for this patch. I often see a small reduction in size, but I rarely find it easier to understand. I wonder what's different... Perhaps our diff algorithm is configured differently or perhaps we review patches differently?


(Shall I create a follow-up issue for that?)

Sure. I'm not 100% about the idea yet, but we can have that discussion in that issue. I think it's sort of contrary to #41.1

Status: Needs review » Needs work

The last submitted patch, 46: 3015438-46.patch, failed testing. View results

wim leers’s picture

You've mentioned this before and I did it for this patch. I often see a small reduction in size, but I rarely find it easier to understand. I wonder what's different...

File renames and/or moving significant pieces of logic are shown as moves rather than additions and deletions. It's super difficult for humans (at least this human!) to go through 100 lines of deletions and 100 lines of additions and verify that only a few LoC actually changed. It's much easier to see this when the diff only contains those few LoC that actually changed. 🙂

gabesullice’s picture

I meant, I wonder how our review process differs, not how an -M5% patch is different from one without it.

Look at Dreditor's diff for EntityDenormalizerBase.php, that's a good example of where I think that flag goes wrong. The deletions/additions bear no relation to one another making it impossible for me to grasp what actually changed, I'd honestly rather look at the whole file and compare it by eye than reverse engineer that mess.

gabesullice’s picture

StatusFileSize
new10.7 KB
new112.41 KB

The #46 failure was because I just needed to update a few ->getIncludes() that I didn't resolve correctly when merging #3026030: [regression] Includes are no longer respected when POSTing/PATCHing.

That made me take a closer look at IncludeResolver where I realized it's no longer necessary to pass it a $base_resource_type or $related field name. This is one of the kinds of simplifications I was hoping would become apparent when this was done :)

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new16.2 KB
new121.4 KB

Alright, this addresses #41.1 by adding a links argument to the ResourceObject class. It's an optional argument and a self link is generated by default. It was nice to see how the LinkCollection object just plugged right in. Because resource objects now carry their own self links, I was able to get rid of another usage of the link manager in ResourceObjectNormalizer (formerly EntityNormalizer).

After that, I realized that having a self link always present would make the toUrl thing really easy to do. So, I went ahead and implemented it. Of course, that led to get rid of another usage of the link manager in EntityResource 🎉. Which of course made me curious... "where else is the link manager being injected?" I found that it is already unused in a place or two and so I just went ahead and removed those too.

If #3024896: Remove Relationship and RelationshipItem classes lands, this all means that there will only be a single service that depends on the link manager after this! That's EntityResource, which only uses it for getting the top-level document's self link (this could easily just be inlined into EntityResource now) and for getting pagination links. The pagination method is a little more complicated, but I don't think there will be a strong argument for keeping it separated from the collection query logic after this.

Status: Needs review » Needs work

The last submitted patch, 51: 3015438-51.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new660 bytes
new121.44 KB

Status: Needs review » Needs work

The last submitted patch, 53: 3015438-53.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new2.14 KB
new122 KB

Ah, internal entities don't have routes either.

wim leers’s picture

#46:

  1. 👍 Ah, of course: the existing docblock was just incomplete. I should've noticed that.
  2. 👍
  3. 👍
  4. 👍
  5. 👍
  6. 👍
  1. Devil's advocate: what if #3024896: Remove Relationship and RelationshipItem classes doesn't land, or doesn't land in the same release?
  1. I see! That sounds solid :)

#49: I usually use 5%, but yes, sometimes you need to tweak that to, say, 20%.

#50: no longer necessary to […] 🎉🎉🎉 — I've always found that super confusing! This interdiff is full of win!

wim leers’s picture

Status: Needs review » Reviewed & tested by the community
StatusFileSize
new1.71 KB
new122.06 KB

#51/#41.1: Your first paragraph there describes everything I expected and hoped 😀 👏 Everything else sounds like the hard labor of our iterating towards maintainability and simplicity is paying off :)

  1. +++ b/src/Controller/EntityResource.php
    @@ -220,14 +220,10 @@ class EntityResource {
    -    $entity_url = $this->linkManager->getEntityLink(
    -      $parsed_entity->uuid(),
    -      $resource_type,
    -      [],
    -      'individual'
    -    );
    -    if ($entity_url) {
    -      $response->headers->set('Location', $entity_url);
    +    if ($resource_type->isLocatable()) {
    +      $url = $resource_object->toUrl()->setAbsolute()->toString(TRUE);
    +      $response->addCacheableDependency($url);
    +      $response->headers->set('Location', $url->getGeneratedUrl());
         }
    

    😍

  2. +++ b/src/Controller/FileUpload.php
    @@ -66,13 +65,6 @@ class FileUpload {
    -  protected $linkManager;
    
    +++ b/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
    @@ -41,13 +40,6 @@ class JsonApiDocumentTopLevelNormalizer extends NormalizerBase implements Denorm
    -  protected $linkManager;
    

    👍 Apparently these were already obsolete service dependencies, fascinating.

  3. +++ b/src/JsonApiResource/LinkCollection.php
    @@ -90,16 +91,29 @@ final class LinkCollection implements \IteratorAggregate {
    -   * @param \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel $context
    +   * @param \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel|\Drupal\jsonapi\JsonApiResource\ResourceObject $context
    ...
    -  public function withContext(JsonApiDocumentTopLevel $context) {
    +  public function withContext($context) {
    

    🤓 ✅ Nit: Let's bring a similar assert here too.

  4. +++ b/src/JsonApiResource/ResourceObject.php
    @@ -49,6 +52,13 @@ class ResourceObject implements CacheableDependencyInterface, ResourceIdentifier
    +   * Resource object level links.
    

    🤓 Isn't this just Resource object's links.?

  5. +++ b/src/JsonApiResource/ResourceObject.php
    @@ -56,13 +66,21 @@ class ResourceObject implements CacheableDependencyInterface, ResourceIdentifier
    +   *   (optional) Any links for the resource object, if a `self` link is not
    +   *   provided, one will be automatically added if the resource is locatable.
    

    🤔 Pondering … why/when is a self link already provided?

  6. +++ b/src/Routing/Routes.php
    @@ -407,7 +407,7 @@ class Routes implements ContainerInjectionInterface {
    -  protected static function getRouteName(ResourceType $resource_type, $route_type) {
    +  public static function getRouteName(ResourceType $resource_type, $route_type) {
    

    👍 Given the benefits this brings above, and given that this is still an @internal class, I'm fine with this.

Overall diffstat: 32 files changed, 757 insertions(+), 761 deletions(-). LoC = status quo. But it brings more simplicity, and has stricter code (more assert()s) as well as better-documented helper methods that will surely prove useful elsewhere in the future too.

I reviewed it in detail. I think this is ready, because it's an iteration on the internals of our code that I believe sets us up for success in the future. Zero functional tests needing to change proves this is purely internal.

gabesullice’s picture

StatusFileSize
new484 bytes
new122 KB

#56

15. I was going to just address your comment in this issue by renaming the variables, but I found a bug while doing so. Because of the bug, I opened a separate issue which is postponed on #3024896: Remove Relationship and RelationshipItem classes. Then, if that issue doesn't land, we can fix the bug and clear up the confusion in that issue. If it does land, then we can close the issue as outdated.

#57

3. Heh, I had exactly this and removed it because it's just duplicating code since it's immediately check in the constructor. OTOH, this will be good for self documenting reasons. Most probably won't bother to look at the constructor esp. since $context is an "internal-only" argument. 👍
4. Yep. Fixed.
5. It isn't. Perhaps this is designing without an existing use-case, but I felt that it would be better design to respect, not override, any links that were provided. Perhaps if we decide to bring jsonapi_hypermedia to the main module some day, the pattern will be to let links be overridden by these constructor $links arguments rather than during the normalization phase. In that case, I think it's conceivable that one might want to override the self link as part of a strategy to create "pretty" URLs. For a commerce site, maybe you'd want product variations to have a more hierarchical URL like /jsonapi/product/shirt/some-uuid-for-shirt/colors/uuid-for-blue-variant vs. /jsonpapi/product/variant/uuid-for-blue-variant.

gabesullice’s picture

StatusFileSize
new572 bytes
new121.85 KB

This should fix 8.7.

gabesullice’s picture

Assigned: Unassigned » e0ipso
Issue summary: View changes

RTBC and passing on 8.5/6/7!

I think this only needs @e0ipso's +1

gabesullice’s picture

Priority: Normal » Major
Related issues: +#2992836: Provide links to resource versions (entity revisions)
wim leers’s picture

Can you update the issue summary to state that as one of the concrete benefits?

e0ipso’s picture

Sorry for not jumping in earlier. Just letting everyone know that this is being discussed in Slack. So progress is happening.

wim leers’s picture

wim leers’s picture

In #23, I pointed out which things would break https://www.drupal.org/project/jsonapi_extras. @gabesullice responded in #25 with the changes JSON:API Extras would need to make. Let's provide that patch here, @gabesullice?

Since https://www.drupal.org/project/consumer_image_styles/releases/8.x-3.x-dev requires jsonapi_extras, there is no way to test that yet either.

wim leers’s picture

Assigned: e0ipso » gabesullice

For #65.

gabesullice’s picture

StatusFileSize
new107.71 KB

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 67: 3015438-67.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Reviewed & tested by the community
StatusFileSize
new1.12 KB
new107.52 KB

Whoops, missed something in #67

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 69: 3015438-69.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Reviewed & tested by the community
StatusFileSize
new911 bytes
new107.53 KB

Almost there.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 71: 3015438-71.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new1.87 KB
new109.18 KB

This should be back to green.

Up till now, I've been setting this back to RTBC since I was making fairly small, unobjectionable changes. This is a little bit bigger of a change, so I'll let you put another set of eyes on it.

gabesullice’s picture

Woot! Whew, that was a challenging rebase!

I've just posted a "patch" for JSON:API Extras here: #3032451-12: Compatibility with upcoming JSON:API 2.2 release: JSON:API Extras 3.4. Unfortunately, I can't post a self-contained patch in this issue because it relies on other pieces of the compatibility patch in that issue.

wim leers’s picture

Assigned: gabesullice » wim leers

Confirmed that with #3032451-12: Compatibility with upcoming JSON:API 2.2 release: JSON:API Extras 3.4, JSON:API Extras works. Consumer Image Styles doesn't work yet though, working on a patch for that.

wim leers’s picture

Verified that this unblocks #2992836 — patch at #2992836-27: Provide links to resource versions (entity revisions).

wim leers’s picture

Verified that this does not conflict with #3014277 — see rebase + comment at #3014277-17: ResourceTypes should know about their fields.

wim leers’s picture

StatusFileSize
new4.97 KB
new108.19 KB

I reviewed this patch once more.

  1. Fixed a few nits not worth calling out here.
  2. +++ b/src/Normalizer/EntityReferenceFieldNormalizer.php
    @@ -44,20 +37,27 @@ class EntityReferenceFieldNormalizer extends FieldNormalizer {
    +  protected $entityAccessChecker;
    
    +++ b/tests/src/Kernel/Normalizer/EntityReferenceFieldNormalizerTest.php
    @@ -137,9 +139,14 @@ class EntityReferenceFieldNormalizerTest extends JsonapiKernelTestBase {
         $this->normalizer = new EntityReferenceFieldNormalizer(
    ...
    +      new EntityAccessChecker(
    +        $this->container->get('jsonapi.resource_type.repository'),
    +        $this->container->get('router.no_access_checks'),
    +        $this->container->get('current_user'),
    +        $this->container->get('entity.repository')
    +      )
    

    This made me suspicious: why is this necessary? So I went to look at EntityReferenceFieldNormalizer. It had this extra dependency injected since #28. It was used in getTranslatedResourceObject().

    But as of the #67 rebase, getTranslatedResourceObject() is gone, yet the service is still injected.

    Either the service can be removed, or this is a bug in the current patch. But the earlier patch iterations had this:

    +    // @todo: determine if getting the entity translation is still relevant since includes are not processed differently.
    +    $translation = $this->entityRepository->getTranslationFromContext($entity);
    

    Indeed it doesn't make sense to get the translated entity reference — it's still the same entity that is being referenced.

    And finally, the line after that:

    +    return $this->entityAccessChecker->getAccessCheckedResourceObject($translation);
    

    That access checking is not something HEAD does either.

    Conclusion: it's fine to remove the entity access checker from EntityReferenceFieldNormalizer altogether, and in doing so it matches HEAD's behavior.

wim leers’s picture

StatusFileSize
new1.26 KB
new107.87 KB

Forgot one hunk :)

The last submitted patch, 78: 3015438-78.patch, failed testing. View results

Status: Needs review » Needs work

The last submitted patch, 79: 3015438-79.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new1.44 KB
new107.77 KB
wim leers’s picture

wim leers’s picture

Besides another detailed review, I've also analyzed impact of this on various things:

  1. JSON:API Extras/JSON:API Defaults (see #75)
  2. Blocked issue verified to be unblocked: #2992836-27: Provide links to resource versions (entity revisions)
  3. I was fairly tempted to still say "no" to this because the benefits may seem a bit iffy. But … #2992836: Provide links to resource versions (entity revisions) clearly shows the value, especially combined with #2994193: [META] The Last Link: A Hypermedia Story in the future. If we want Drupal contrib modules to be able to provide extensible HATEOAS links (and @e0ipso and @gabesullice both felt very strongly about this long before I did!), then a wrapper object is a necessity. To then name that new object ResourceObject just like in the JSON:API spec and bringing more clarity to the rest of the code base makes it a win-win!
  4. Related issue verified to be unaffected: #3014277-17: ResourceTypes should know about their fields
  5. Filed issue to further improve the understandability of the codebase — this change made sense a long time ago but much more so after this lands: #3032679: Clean-up: rename EntityCollection to Data
  6. The one remaining thing (besides making #82 pass on 8.7) is updating the patch at #3032468: Compatibility with upcoming JSON:API 2.2 release: JSON:API Extras 3.4 to account for the changes here. I started doing that, but found it quite tricky. I just talked to @gabesullice and his answer made me feel like a fool: I was trying to keep the existing structure (which is to have a normalizer that kicks in for a particular type of entity with a particular kind of value — that's what \Drupal\consumer_image_styles\Normalizer\ImageEntityNormalizer does). But he pointed out that a far simpler solution is now possible: change it to a LinkCollection normalizer that decorates the default normalization only when a particular type of entity with a particular kind of value is present — because all this normalizer does, is adding links! So it will serve as a further validation of #2994193: [META] The Last Link: A Hypermedia Story.

Assigning to @gabesullice for that last point.

gabesullice’s picture

Status: Needs review » Reviewed & tested by the community

I've confirmed the change in #78.2 is fine. That code has its roots in #2794431: [META] Formalize translations support. However, it should have been removed in #2997600: Resolve included resources prior to normalization since include resolution now happens outside the normalization system. Access/translation handling is handled in the IncludeResolver already, so we lose nothing by removing it.

Given that @Wim Leers already reviewed everything once and I've confirmed the changes he made, marking this RTBC.

Now going to work on the consumer_image_styles patch.

gabesullice’s picture

StatusFileSize
new1.76 KB
new109.31 KB

Ah, I forgot about the 8.7 failure in #82 (and before).

After digging in, it looks like this failure was already anticipated in #3022584: Consolidate and simplify NormalizerValue objects: introduce CacheableNormalization, note this removed hunk:

+++ b/src/Normalizer/ResourceObjectNormalizer.php
@@ -3,90 +3,104 @@
-    // Perform the default entity normalization, extract all values from the
-    // resulting CacheableNormalization object.
-    // @see \Drupal\jsonapi\Normalizer\EntityNormalizer::normalize()
-    $normalized = $this->serializer->normalize($entity, $format, $context);
...
-    // Reconstruct a CacheableNormalization object, this time with only the
-    // label field.
-    $label_only_attributes = [$public_field_label_name => $rasterized['attributes'][$public_field_label_name]];
-    $rasterized['attributes'] = $label_only_attributes;
-    // This associates the cacheability of all normalized fields with the
-    // returned CacheableNormalization. This is not necessary and can result in
-    // unwanted cache invalidations. Only the cacheability of the entity and its
-    // label field is truly required.
-    // @todo: ensure precise and correct cacheability is returned in https://www.drupal.org/project/jsonapi/issues/3015438.
-    return new CacheableNormalization(CacheableMetadata::createFromObject($normalized), $rasterized);

That hunk is referring to this issue!

HEAD is wrongly associating the cacheability of every field even when only the label field is being normalized. That's why we didn't notice this failure in #3031750: Drupal core compatibility: User `mail` field varies by the ‘user’ cache context in Drupal >= 8.7.

TL;DR:

This patch is more correct WRT to field cacheability than HEAD, so we need to update our test expectations for that.

wim leers’s picture

Assigned: gabesullice » Unassigned

Ah, yes, of course, I remember discussing that! Perfect!

Per #85, I think we should commit this as soon as we have a patch that updates consumer_image_styles to keep it working.

gabesullice’s picture

StatusFileSize
new1.44 KB
new110.47 KB

This fixes a type hint and a small pre-existing bug in LinkCollection that was revealed while I was working on #3032468: Compatibility with upcoming JSON:API 2.2 release: JSON:API Extras 3.4

wim leers’s picture

I just manually ran tests for Consumer Image Styles with #3032468-4: Compatibility with upcoming JSON:API 2.2 release: JSON:API Extras 3.4 applied and using #87. I reproduced the bug that #89 fixes. So it all checks out, and now the last thing of #85 has been addressed!

Let's do this! 💪

effulgentsia’s picture

+++ b/src/LabelOnlyResourceObject.php
@@ -2,34 +2,15 @@
-class LabelOnlyEntity implements CacheableDependencyInterface, ResourceIdentifierInterface {
+final class LabelOnlyResourceObject extends ResourceObject {

While we're renaming anyway, any reason not to move this to the JsonApiResource namespace, where the base class and other similar classes are?

I'm fine with that to be a follow-up if this is otherwise ready for commit.

wim leers’s picture

StatusFileSize
new3.03 KB
new110.56 KB

WELL SPOTTED! 🧐

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 92: 3015438-92.patch, failed testing. View results

wim leers’s picture

Status: Needs work » Reviewed & tested by the community
StatusFileSize
new667 bytes
new110.62 KB

One silly mistake.

  • Wim Leers committed 07ecd18 on 8.x-2.x authored by gabesullice
    Issue #3015438 by gabesullice, Wim Leers, e0ipso, effulgentsia: Wrap...
wim leers’s picture

Status: Reviewed & tested by the community » Fixed

🎉

gabesullice’s picture

🎉🙌

wim leers’s picture

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.