backend, Locastic Feb 26

Clean and easy API configuration with Sylius

3 min read –

New API Platform based API, introduced in Sylius 1.8, is a welcomed addition to this rising ecommerce platform.

This article presents an alternative take on Sylius API modification, for a better developer experience IMHO. It involves replacing vendor internals so it might not be your cup of tea. If that’s the case, stick with Petar’s instructions mentioned above.

Still, one thing that bothers me with the new Sylius API is how API modification works. Seems that the only way to introduce changes to Sylius built-in API is by massive duplication of “vendor” content (XML files). For every change of built-in API resource, complete API resource metadata needs to be copied from “vendor” to our codebase as pointed by my colleague Petar a few months ago. This is far from ideal because the Sylius could introduce their own changes in this API resource and they won’t be applied to our application. As a matter of fact, a bunch of changes already made it’s way with Sylius 1.9. Also, it enforces usage of XML format even if other format is preferred.

Sylius API configuration

Sylius keeps it’s API resource configuration as a series of XML files in the following location:

vendor/sylius/sylius/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources

Naturally, we are free to add additional API resources in our codebase. By default, only annotations are supported. Personally, I prefer YAML format so I usually change config/packages/api_platform.yaml this way to remove annotation and add YAML support:

api_platform:
    mapping:
        paths: ['%kernel.project_dir%/config/api_platform']

Both annotation and YAML configuration works great for brand new API resources, just the way you would expect. But if you try to change something in Sylius’ built-in resources (Product, Channel, etc.), you’ll soon discover issues.

Most of the time your changes won’t be applied at all. And in some weird cases they will. After a little bit of digging I discovered that the root cause of a problem is the following class:

\ApiPlatform\Core\Metadata\Resource\Factory\ExtractorResourceMetadataFactory

It supports configuration overriding, at first sight, but if you look closely into it’s merging algorithm – update() method – you’ll realize that it completely discards child configuration sections if they are already defined in parent configuration. For example, if any item operation is defined, additional can’t be added.

Solution is to change this merging algorithm to behave similarly to PHP’s array_merge() function, only recursively (unfortunately, array_merge_recursive() doesn’t fit here).

End goal

Appending new Product filter should be simple as this:

'%sylius.model.product.class%':
  collectionOperations:
    shop_get:
      filters:
        - app.my_product_filter

Adding new Channel operation should be simple as this:

'%sylius.model.channel.class%':
  collectionOperations
    my_new_operation:
      method: GET
      path: /shop/channels
      normalization_context:
        groups: ['shop:channel:read']

What about removing parts of original metadata? If configs are merged, we need some way of declaring that we need to remove something instead of merging. Let’s introduce “unset” keyword:

'%sylius.model.channel.class%':
  collectionOperations
    admin_post (unset): ~

This way, unwanted config sections can be removed.

This unsetting could be useful with numeric arrays also. Let’s say we don’t want to append an operation filter but to replace it completely:

'%sylius.model.product.class%':
  collectionOperations:
    shop_get:
      filters (unset):
        - app.my_product_filter

This way the “filter” config section is first removed and then defined, resulting with only the “app.my_product_filter” filter (all parent filters are gone).

Customization

Copy from vendor/api-platform/core/src/Metadata/Resource/Factory/ExtractorResourceMetadataFactory.php to src/ApiPlatform/MergingExtractorResourceMetadataFactory.php and modify:

<?php

declare(strict_types=1);

namespace App\ApiPlatform;

// ...

class MergingExtractorResourceMetadataFactory implements ResourceMetadataFactoryInterface
{
    // ...original class content, except update() method...

    private function update(ResourceMetadata $resourceMetadata, array $metadata): ResourceMetadata
    {
        foreach (['shortName', 'description', 'iri', 'itemOperations', 'collectionOperations', 'subresourceOperations', 'graphql', 'attributes'] as $propertyName) {
            $propertyValue = $this->resolveResourceMetadataPropertyValue($propertyName, $resourceMetadata, $metadata);
            if (null !== $propertyValue) {
                $resourceMetadata = $resourceMetadata->{'with' . ucfirst($propertyName)}($propertyValue);
            }
        }

        return $resourceMetadata;
    }

    /** @return mixed */
    private function resolveResourceMetadataPropertyValue(
        string $propertyName,
        ResourceMetadata $parentResourceMetadata,
        array $childResourceMetadata
    ) {
        $parentPropertyValue = $parentResourceMetadata->{'get' . ucfirst($propertyName)}();

        $childPropertyValue = $childResourceMetadata[$propertyName];
        if (null === $childPropertyValue) {
            return $parentPropertyValue;
        }

        if (null === $parentPropertyValue) {
            return $childPropertyValue;
        }

        if (is_array($parentPropertyValue)) {
            if (!is_array($childPropertyValue)) {
                throw new \InvalidArgumentException(sprintf(
                    'Invalid child property value type for property "%s", expected array',
                    $propertyName,
                ));
            }

            return $this->mergeConfigs($parentPropertyValue, $childPropertyValue);
        }

        return $childPropertyValue;
    }

    private function mergeConfigs(...$configs): array
    {
        $resultingConfig = [];

        foreach ($configs as $config) {
            foreach ($config as $newKey => $newValue) {
                $unsetNewKey = false;
                if (is_string($newKey) && 1 === preg_match('/^(.*[^ ]) +\\(unset\\)$/', $newKey, $matches)) {
                    [, $newKey] = $matches;
                    $unsetNewKey = true;
                }

                if ($unsetNewKey) {
                    unset($resultingConfig[$newKey]);

                    if (null === $newValue) {
                        continue;
                    }
                }

                if (is_integer($newKey)) {
                    $resultingConfig[] = $newValue;
                } elseif (isset($resultingConfig[$newKey]) && is_array($resultingConfig[$newKey]) && is_array($newValue)) {
                    $resultingConfig[$newKey] = $this->mergeConfigs($resultingConfig[$newKey], $newValue);
                } else {
                    $resultingConfig[$newKey] = $newValue;
                }
            }
        }

        return $resultingConfig;
    }
}

To put this class in use, manual service container modification is necessary.

config/service.yaml:

services:
  # ...

  api_platform.metadata.resource.metadata_factory.yaml:
    class: 'App\ApiPlatform\MergingExtractorResourceMetadataFactory'
    decorates: 'api_platform.metadata.resource.metadata_factory'
    decoration_priority: 40
    arguments:
      - '@api_platform.metadata.extractor.yaml'
      - '@api_platform.metadata.resource.metadata_factory.yaml.inner'

This service definition replaces the original one. Class name is the only difference here. Simple decoration is not enough because we want to eliminate the original class before it causes damage to resource metadata.

Note that this customization is tailored for YAML configuration format. If you plan to use XML, replace the api_platform.metadata.resource.metadata_factory.xml service definition instead (copy original definition and replace the class name).

For annotations, “api_platform.metadata.resource.metadata_factory.annotation” service should be replaced but you’ll have to apply class customization from scratch because a different class is used (AnnotationResourceMetadataFactory). However, this should be pretty straightforward, you’ll have to change createWith() method only.

Conclusion

Described customization allows you to create API resource metadata in a more uniform way (single format) and to extend Sylius’ built-in API resources without configuration duplication. Also, your changes will be visible at first glance.