Skip to content

Commit

Permalink
feat: resource translation
Browse files Browse the repository at this point in the history
  • Loading branch information
alanpoulain committed Jul 2, 2021
1 parent 6336f64 commit 6793e3d
Show file tree
Hide file tree
Showing 30 changed files with 1,205 additions and 20 deletions.
12 changes: 12 additions & 0 deletions features/jsonld/context.feature
Expand Up @@ -86,3 +86,15 @@ Feature: JSON-LD contexts generation
}
}
"""

Scenario: The JSON-LD context of a translatable resource contains the language
When I send a "GET" request to "/dummy_translatables"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON node "@context.@language" should be equal to "en"
When I send a "GET" request to "/dummy_translatables/fr"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON node "@context.@language" should be equal to "fr"
92 changes: 92 additions & 0 deletions features/translation/default_translation.feature
@@ -0,0 +1,92 @@
Feature: Use default translation for a resource if available
In order to translate API resources
As a client software developer
The API should return translated fields for a given locale

@createSchema
Scenario: A resource is translated for a given locale
Given there is a translatable dummy with its translations
When I send a "GET" request to "/dummy_translatables"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON node "hydra:member[0].name" should be equal to "Dummy translated in English"
And the JSON node "hydra:member[0].description" should be equal to "It's a dummy!"
When I send a "GET" request to "/dummy_translatables/fr"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON node "hydra:member[0].name" should be equal to "Dummy traduit en français"
And the JSON node "hydra:member[0].description" should be equal to "C'est un dummy !"
And the JSON node "hydra:member[0].notTranslatedField" should be equal to "not translated"

Scenario: A translation can be updated for a resource
When I add "Content-Type" header equal to "application/ld+json"
And I send a "PUT" request to "/dummy_translatables/1/fr" with body:
"""
{
"name": "Dummy mieux traduit en français",
"notTranslatedField": "really not translated"
}
"""
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON node "name" should be equal to "Dummy mieux traduit en français"
And the JSON node "description" should be equal to "C'est un dummy !"
And the JSON node "notTranslatedField" should be equal to "really not translated"
When I send a "GET" request to "/dummy_translatables/fr"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON node "hydra:member[0].name" should be equal to "Dummy mieux traduit en français"
And the JSON node "hydra:member[0].description" should be equal to "C'est un dummy !"
And the JSON node "hydra:member[0].notTranslatedField" should be equal to "really not translated"

Scenario: A translation can be added to a resource
When I add "Content-Type" header equal to "application/ld+json"
And I send a "PUT" request to "/dummy_translatables/1/es" with body:
"""
{
"name": "Dummy traducido al español",
"description": "¡Es un dummy!",
"notTranslatedField": "truly not translated"
}
"""
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON node "name" should be equal to "Dummy traducido al español"
And the JSON node "description" should be equal to "¡Es un dummy!"
And the JSON node "notTranslatedField" should be equal to "truly not translated"
When I send a "GET" request to "/dummy_translatables/es"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON node "hydra:member[0].name" should be equal to "Dummy traducido al español"
And the JSON node "hydra:member[0].description" should be equal to "¡Es un dummy!"
And the JSON node "hydra:member[0].notTranslatedField" should be equal to "truly not translated"

Scenario: A resource can be created with its translation
When I add "Content-Type" header equal to "application/ld+json"
And I send a "POST" request to "/dummy_translatables/fr" with body:
"""
{
"name": "Autre Dummy traduit en français",
"description": "C'est un autre dummy.",
"notTranslatedField": "n/a"
}
"""
Then the response status code should be 201
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON node "name" should be equal to "Autre Dummy traduit en français"
And the JSON node "description" should be equal to "C'est un autre dummy."
And the JSON node "notTranslatedField" should be equal to "n/a"
When I send a "GET" request to "/dummy_translatables/fr"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON node "hydra:member[1].name" should be equal to "Autre Dummy traduit en français"
And the JSON node "hydra:member[1].description" should be equal to "C'est un autre dummy."
And the JSON node "hydra:member[1].notTranslatedField" should be equal to "n/a"
7 changes: 5 additions & 2 deletions src/Annotation/ApiResource.php
Expand Up @@ -71,7 +71,8 @@
* @Attribute("urlGenerationStrategy", type="int"),
* @Attribute("validationGroups", type="mixed"),
* @Attribute("exceptionToStatus", type="array"),
* @Attribute("queryParameterValidationEnabled", type="bool")
* @Attribute("queryParameterValidationEnabled", type="bool"),
* @Attribute("translation", type="array")
* )
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
Expand Down Expand Up @@ -174,6 +175,7 @@ final class ApiResource
* @param int $urlGenerationStrategy
* @param array $exceptionToStatus https://api-platform.com/docs/core/errors/#fine-grained-configuration
* @param bool $queryParameterValidationEnabled
* @param array $translation
*
* @throws InvalidArgumentException
*/
Expand Down Expand Up @@ -225,7 +227,8 @@ public function __construct(
?int $urlGenerationStrategy = null,
?bool $compositeIdentifier = null,
?array $exceptionToStatus = null,
?bool $queryParameterValidationEnabled = null
?bool $queryParameterValidationEnabled = null,
?array $translation = null
) {
if (!\is_array($description)) { // @phpstan-ignore-line Doctrine annotations support
[$publicProperties, $configurableAttributes] = self::getConfigMetadata();
Expand Down
Expand Up @@ -120,6 +120,7 @@ public function load(array $configs, ContainerBuilder $container): void
$this->registerDoctrineOrmConfiguration($container, $config, $loader);
$this->registerDoctrineMongoDbOdmConfiguration($container, $config, $loader);
$this->registerHttpCacheConfiguration($container, $config, $loader);
$this->registerTranslationConfiguration($loader);
$this->registerValidatorConfiguration($container, $config, $loader);
$this->registerDataCollectorConfiguration($container, $config, $loader);
$this->registerMercureConfiguration($container, $config, $loader);
Expand Down Expand Up @@ -616,6 +617,11 @@ private function getFormats(array $configFormats): array
return $formats;
}

private function registerTranslationConfiguration(XmlFileLoader $loader): void
{
$loader->load('translation.xml');
}

private function registerValidatorConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader): void
{
if (interface_exists(ValidatorInterface::class)) {
Expand Down
1 change: 1 addition & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/api.xml
Expand Up @@ -117,6 +117,7 @@
<argument type="tagged" tag="api_platform.data_transformer" on-invalid="ignore" />
<argument type="service" id="api_platform.metadata.resource.metadata_factory" on-invalid="ignore" />
<argument type="service" id="api_platform.security.resource_access_checker" on-invalid="ignore" />
<argument type="service" id="api_platform.translation.resource_translator" on-invalid="ignore" />

<!-- Run before serializer.normalizer.json_serializable -->
<tag name="serializer.normalizer" priority="-895" />
Expand Down
1 change: 1 addition & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/graphql.xml
Expand Up @@ -222,6 +222,7 @@
<argument type="tagged" tag="api_platform.data_transformer" on-invalid="ignore" />
<argument type="service" id="api_platform.metadata.resource.metadata_factory" on-invalid="ignore" />
<argument type="service" id="api_platform.security.resource_access_checker" on-invalid="ignore" />
<argument type="service" id="api_platform.translation.resource_translator" on-invalid="ignore" />

<!-- Run before serializer.normalizer.json_serializable -->
<tag name="serializer.normalizer" priority="-890" />
Expand Down
1 change: 1 addition & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/hal.xml
Expand Up @@ -42,6 +42,7 @@
<argument type="tagged" tag="api_platform.data_transformer" on-invalid="ignore" />
<argument type="service" id="api_platform.metadata.resource.metadata_factory" on-invalid="ignore" />
<argument type="service" id="api_platform.security.resource_access_checker" on-invalid="ignore" />
<argument type="service" id="api_platform.translation.resource_translator" on-invalid="ignore" />

<!-- Run before serializer.normalizer.json_serializable -->
<tag name="serializer.normalizer" priority="-890" />
Expand Down
1 change: 1 addition & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml
Expand Up @@ -43,6 +43,7 @@
<argument type="collection" />
<argument type="tagged" tag="api_platform.data_transformer" on-invalid="ignore" />
<argument type="service" id="api_platform.security.resource_access_checker" on-invalid="ignore" />
<argument type="service" id="api_platform.translation.resource_translator" on-invalid="ignore" />

<!-- Run before serializer.normalizer.json_serializable -->
<tag name="serializer.normalizer" priority="-890" />
Expand Down
3 changes: 3 additions & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml
Expand Up @@ -11,6 +11,8 @@
<argument type="service" id="api_platform.metadata.property.name_collection_factory" />
<argument type="service" id="api_platform.metadata.property.metadata_factory" />
<argument type="service" id="api_platform.router" />
<argument>null</argument>
<argument type="service" id="api_platform.translation.resource_translator" />
</service>

<!-- Serializer -->
Expand All @@ -28,6 +30,7 @@
<argument type="collection" />
<argument type="tagged" tag="api_platform.data_transformer" on-invalid="ignore" />
<argument type="service" id="api_platform.security.resource_access_checker" on-invalid="ignore" />
<argument type="service" id="api_platform.translation.resource_translator" on-invalid="ignore" />

<!-- Run before serializer.normalizer.json_serializable -->
<tag name="serializer.normalizer" priority="-890" />
Expand Down
16 changes: 16 additions & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/translation.xml
@@ -0,0 +1,16 @@
<?xml version="1.0" ?>

<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

<services>
<service id="api_platform.translation.resource_translator" class="ApiPlatform\Core\Translation\ResourceTranslator" public="false">
<argument type="service" id="request_stack" />
<argument type="service" id="api_platform.property_accessor" />
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
</service>
<service id="ApiPlatform\Core\Translation\ResourceTranslatorInterface" alias="api_platform.translation.resource_translator" />
</services>

</container>
5 changes: 3 additions & 2 deletions src/GraphQl/Serializer/ItemNormalizer.php
Expand Up @@ -23,6 +23,7 @@
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Security\ResourceAccessCheckerInterface;
use ApiPlatform\Core\Serializer\ItemNormalizer as BaseItemNormalizer;
use ApiPlatform\Core\Translation\ResourceTranslatorInterface;
use ApiPlatform\Core\Util\ClassInfoTrait;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
Expand All @@ -46,9 +47,9 @@ final class ItemNormalizer extends BaseItemNormalizer

private $identifiersExtractor;

public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, IdentifiersExtractorInterface $identifiersExtractor, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, ItemDataProviderInterface $itemDataProvider = null, bool $allowPlainIdentifiers = false, LoggerInterface $logger = null, iterable $dataTransformers = [], ResourceMetadataFactoryInterface $resourceMetadataFactory = null, ResourceAccessCheckerInterface $resourceAccessChecker = null)
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, IdentifiersExtractorInterface $identifiersExtractor, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, ItemDataProviderInterface $itemDataProvider = null, bool $allowPlainIdentifiers = false, LoggerInterface $logger = null, iterable $dataTransformers = [], ResourceMetadataFactoryInterface $resourceMetadataFactory = null, ResourceAccessCheckerInterface $resourceAccessChecker = null, ResourceTranslatorInterface $resourceTranslator = null)
{
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $itemDataProvider, $allowPlainIdentifiers, $logger ?: new NullLogger(), $dataTransformers, $resourceMetadataFactory, $resourceAccessChecker);
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $itemDataProvider, $allowPlainIdentifiers, $logger ?: new NullLogger(), $dataTransformers, $resourceMetadataFactory, $resourceAccessChecker, $resourceTranslator);

$this->identifiersExtractor = $identifiersExtractor;
}
Expand Down
5 changes: 3 additions & 2 deletions src/JsonApi/Serializer/ItemNormalizer.php
Expand Up @@ -24,6 +24,7 @@
use ApiPlatform\Core\Serializer\AbstractItemNormalizer;
use ApiPlatform\Core\Serializer\CacheKeyTrait;
use ApiPlatform\Core\Serializer\ContextTrait;
use ApiPlatform\Core\Translation\ResourceTranslatorInterface;
use ApiPlatform\Core\Util\ClassInfoTrait;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyInfo\Type;
Expand Down Expand Up @@ -51,9 +52,9 @@ final class ItemNormalizer extends AbstractItemNormalizer

private $componentsCache = [];

public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor, ?NameConverterInterface $nameConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, array $defaultContext = [], iterable $dataTransformers = [], ResourceAccessCheckerInterface $resourceAccessChecker = null)
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor, ?NameConverterInterface $nameConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, array $defaultContext = [], iterable $dataTransformers = [], ResourceAccessCheckerInterface $resourceAccessChecker = null, ResourceTranslatorInterface $resourceTranslator = null)
{
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, null, null, false, $defaultContext, $dataTransformers, $resourceMetadataFactory, $resourceAccessChecker);
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, null, null, false, $defaultContext, $dataTransformers, $resourceMetadataFactory, $resourceAccessChecker, $resourceTranslator);
}

/**
Expand Down
8 changes: 7 additions & 1 deletion src/JsonLd/ContextBuilder.php
Expand Up @@ -18,6 +18,7 @@
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
use ApiPlatform\Core\Translation\ResourceTranslatorInterface;
use ApiPlatform\Core\Util\ClassInfoTrait;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;

Expand All @@ -42,15 +43,17 @@ final class ContextBuilder implements AnonymousContextBuilderInterface
* @var NameConverterInterface|null
*/
private $nameConverter;
private $resourceTranslator;

public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, UrlGeneratorInterface $urlGenerator, NameConverterInterface $nameConverter = null)
public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, UrlGeneratorInterface $urlGenerator, NameConverterInterface $nameConverter = null, ResourceTranslatorInterface $resourceTranslator = null)
{
$this->resourceNameCollectionFactory = $resourceNameCollectionFactory;
$this->resourceMetadataFactory = $resourceMetadataFactory;
$this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
$this->propertyMetadataFactory = $propertyMetadataFactory;
$this->urlGenerator = $urlGenerator;
$this->nameConverter = $nameConverter;
$this->resourceTranslator = $resourceTranslator;
}

/**
Expand Down Expand Up @@ -151,6 +154,9 @@ public function getAnonymousResourceContext($object, array $context = [], int $r
private function getResourceContextWithShortname(string $resourceClass, int $referenceType, string $shortName): array
{
$context = $this->getBaseContext($referenceType);
if ($this->resourceTranslator && $this->resourceTranslator->isResourceClassTranslatable($resourceClass) && $locale = $this->resourceTranslator->getLocale()) {
$context['@language'] = $locale;
}

foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $propertyName) {
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName);
Expand Down

0 comments on commit 6793e3d

Please sign in to comment.