diff --git a/src/Doctrine/LocalizedCollectionExtension.php b/src/Doctrine/LocalizedCollectionExtension.php index 8755ef9..005e21c 100644 --- a/src/Doctrine/LocalizedCollectionExtension.php +++ b/src/Doctrine/LocalizedCollectionExtension.php @@ -38,7 +38,7 @@ final class LocalizedCollectionExtension implements QueryResultCollectionExtensi ?Operation $operation = null, array $context = [], ): iterable { - $query = $this->addLocalizationHints($queryBuilder, $this->getContextLanguages($context)); + $query = $this->addLocalizationHints($queryBuilder, $this->getAcceptedLanguages($context)); if (\count($queryBuilder->getAllAliases()) === 1) { $query->setHint(CountWalker::HINT_DISTINCT, false); diff --git a/src/Doctrine/LocalizedExtensionTrait.php b/src/Doctrine/LocalizedExtensionTrait.php index 32f02e6..821120a 100644 --- a/src/Doctrine/LocalizedExtensionTrait.php +++ b/src/Doctrine/LocalizedExtensionTrait.php @@ -3,17 +3,26 @@ namespace App\Doctrine; use ApiPlatform\Metadata\Operation; -use App\Entity\Interface\LocalizedContentInterface; +use App\Entity\Interface\LocalizedEntityInterface; use App\Service\LocalizationService; use Doctrine\ORM\Query; use Doctrine\ORM\QueryBuilder; use Gedmo\Translatable\TranslatableListener; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Contracts\Service\Attribute\Required; trait LocalizedExtensionTrait { + protected RequestStack $requestStack; + protected LocalizationService $localizationService; + #[Required] + public function setRequestStack(RequestStack $requestStack) + { + $this->requestStack = $requestStack; + } + #[Required] public function setLocalizationService(LocalizationService $localizationService) { @@ -24,7 +33,7 @@ trait LocalizedExtensionTrait { $reflectionClass = new \ReflectionClass($resourceClass); - return $reflectionClass->implementsInterface(LocalizedContentInterface::class); + return $reflectionClass->implementsInterface(LocalizedEntityInterface::class); } private function addLocalizationHints(QueryBuilder $queryBuilder, array $locales): Query @@ -46,9 +55,15 @@ trait LocalizedExtensionTrait return $query; } - private function getContextLanguages(array $context): array + private function getAcceptedLanguages(array $context): array { - $tags = $context['request']->headers->get('Accept-Language', ''); + $request = $this->requestStack->getCurrentRequest(); + + if (\array_key_exists('request', $context)) { + $request = $context['request']; + } + + $tags = $request->headers->get('Accept-Language', ''); return $this->localizationService->getLanguages($tags); } diff --git a/src/Doctrine/LocalizedItemExtension.php b/src/Doctrine/LocalizedItemExtension.php index ed83877..a9cd4a5 100644 --- a/src/Doctrine/LocalizedItemExtension.php +++ b/src/Doctrine/LocalizedItemExtension.php @@ -33,7 +33,7 @@ final class LocalizedItemExtension implements QueryResultItemExtensionInterface ?Operation $operation = null, array $context = [], ): ?object { - $query = $this->addLocalizationHints($queryBuilder, $this->getContextLanguages($context)); + $query = $this->addLocalizationHints($queryBuilder, $this->getAcceptedLanguages($context)); return $query->getOneOrNullResult(); } diff --git a/src/Entity/Interface/LocalizedContentInterface.php b/src/Entity/Interface/LocalizedEntityInterface.php similarity index 94% rename from src/Entity/Interface/LocalizedContentInterface.php rename to src/Entity/Interface/LocalizedEntityInterface.php index 0dc6338..3ba47b5 100644 --- a/src/Entity/Interface/LocalizedContentInterface.php +++ b/src/Entity/Interface/LocalizedEntityInterface.php @@ -11,7 +11,7 @@ use Gedmo\Translatable\Translatable; * * @see https://github.com/doctrine-extensions/DoctrineExtensions/blob/main/doc/translatable.md#translatable-behavior-extension-for-doctrine */ -interface LocalizedContentInterface extends Translatable +interface LocalizedEntityInterface extends Translatable { public function getId(): ?int; diff --git a/src/Entity/Project/Project.php b/src/Entity/Project/Project.php index f06f3d1..bd39382 100644 --- a/src/Entity/Project/Project.php +++ b/src/Entity/Project/Project.php @@ -4,9 +4,9 @@ namespace App\Entity\Project; use App\Entity\Accounting\Accounting; use App\Entity\Interface\AccountingOwnerInterface; -use App\Entity\Interface\LocalizedContentInterface; +use App\Entity\Interface\LocalizedEntityInterface; use App\Entity\Interface\UserOwnedInterface; -use App\Entity\Trait\LocalizedContent; +use App\Entity\Trait\LocalizedEntityTrait; use App\Entity\Trait\MigratedEntity; use App\Entity\Trait\TimestampedCreationEntity; use App\Entity\Trait\TimestampedUpdationEntity; @@ -23,9 +23,9 @@ use Gedmo\Mapping\Annotation as Gedmo; #[MapProvider(EntityMapProvider::class)] #[ORM\Entity(repositoryClass: ProjectRepository::class)] -class Project implements UserOwnedInterface, AccountingOwnerInterface, LocalizedContentInterface +class Project implements UserOwnedInterface, AccountingOwnerInterface, LocalizedEntityInterface { - use LocalizedContent; + use LocalizedEntityTrait; use MigratedEntity; use TimestampedCreationEntity; use TimestampedUpdationEntity; diff --git a/src/Entity/Trait/LocalizedContent.php b/src/Entity/Trait/LocalizedEntityTrait.php similarity index 97% rename from src/Entity/Trait/LocalizedContent.php rename to src/Entity/Trait/LocalizedEntityTrait.php index 0ce1bb8..0e641cb 100644 --- a/src/Entity/Trait/LocalizedContent.php +++ b/src/Entity/Trait/LocalizedEntityTrait.php @@ -5,7 +5,7 @@ namespace App\Entity\Trait; use Doctrine\ORM\Mapping as ORM; use Gedmo\Mapping\Annotation as Gedmo; -trait LocalizedContent +trait LocalizedEntityTrait { #[Gedmo\Locale] private ?string $locale = null; diff --git a/src/OpenApi/OpenApiDescription.md b/src/OpenApi/OpenApiDescription.md index 8f11645..3e53ec8 100644 --- a/src/OpenApi/OpenApiDescription.md +++ b/src/OpenApi/OpenApiDescription.md @@ -33,8 +33,8 @@ The v4 API accepts localization of content. Resources such as Projects can have Retrieval of content in different locales is performed via standard HTTP [content negotiation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation). When the request supplies an `Accept-Language` header, the API will retrieve localized versions of the content where available, falling back to the default locale of the API instance, and finally to the first available localization of the content regardless of the request preferences or the API defaults if it cannot find suitable localizations. -Submission of localized content is performed over standard REST content submission with the addition of a `Content-Language` header to a POST, PUT or PATCH request. Only the first tag will be used to determine the submitted localization. +Submission of localized content is performed over standard REST content submission with the addition of a `Content-Language` header to a POST, PUT or PATCH request. Only the first tag will be used to determine the submitted localization. When a localizable resource is created with a non-localized request (a request without a `Content-Language` header), the default locale of the API instance is used. -Removal of localized content is performed over standard REST content deletion with the addition of a `Content-Language` header to a DELETE request. A delete request for localized content will act partially and remove only the requested localizations, not the entire resource. To remove the resource you can send a non-localized request omitting the `Content-Language` header. Unlike on submission v4 will process all language tags included in the header and remove every matching localization. +Removal of localized content is performed over standard REST content deletion with the addition of a `Content-Language` header to a DELETE request. A delete request for localized content will act partially and remove only the requested localizations, not the entire resource. To remove the resource you can send a non-localized request. Unlike on submission v4 will process all language tags included in the header and remove every matching localization. -Localizations are assumed to be one per language globally. Language tags will be parsed to match to an [ISO 639](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes) two-letter languade code, e.g: a value of "en_US" will be converted to "en", ignoring the "US" subtag. +Localizations are assumed to be one per language globally. Language tags will be parsed to match to an [ISO 639](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes) two-letter languade code, e.g: a value of "en_US, en_GB" will be converted to "en", ignoring the "US" and "GB" subtags. diff --git a/src/State/ApiResourceStateProcessor.php b/src/State/ApiResourceStateProcessor.php index f2994ce..7c275c7 100644 --- a/src/State/ApiResourceStateProcessor.php +++ b/src/State/ApiResourceStateProcessor.php @@ -2,22 +2,15 @@ namespace App\State; -use ApiPlatform\Doctrine\Common\State\PersistProcessor; -use ApiPlatform\Doctrine\Common\State\RemoveProcessor; use ApiPlatform\Doctrine\Orm\State\Options; -use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Mapping\AutoMapper; -use Symfony\Component\DependencyInjection\Attribute\Autowire; class ApiResourceStateProcessor implements ProcessorInterface { public function __construct( - #[Autowire(service: RemoveProcessor::class)] - private ProcessorInterface $deleteProcessor, - #[Autowire(service: PersistProcessor::class)] - private ProcessorInterface $persistProcessor, + private EntityStateProcessor $entityStateProcessor, private AutoMapper $autoMapper, ) {} @@ -28,14 +21,12 @@ class ApiResourceStateProcessor implements ProcessorInterface { $entity = $this->getEntity($data, $operation->getStateOptions()); - if ($operation instanceof DeleteOperationInterface) { - $this->deleteProcessor->process($entity, $operation, $uriVariables, $context); + $entity = $this->entityStateProcessor->process($entity, $operation, $uriVariables, $context); + if ($entity === null) { return null; } - $this->persistProcessor->process($entity, $operation, $uriVariables, $context); - return $this->autoMapper->map($entity, $data); } diff --git a/src/State/EntityStateProcessor.php b/src/State/EntityStateProcessor.php index 98ade6a..20b18e2 100644 --- a/src/State/EntityStateProcessor.php +++ b/src/State/EntityStateProcessor.php @@ -7,20 +7,20 @@ use ApiPlatform\Doctrine\Common\State\RemoveProcessor; use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; -use App\Entity\Interface\LocalizedContentInterface; -use App\Service\LocalizationService; +use App\Entity\Interface\LocalizedEntityInterface; use Doctrine\ORM\EntityManagerInterface; use Gedmo\Translatable\Entity\Translation; use Symfony\Component\DependencyInjection\Attribute\Autowire; class EntityStateProcessor implements ProcessorInterface { + use LocalizedStateProcessorTrait; + public function __construct( #[Autowire(service: RemoveProcessor::class)] private ProcessorInterface $deleteProcessor, #[Autowire(service: PersistProcessor::class)] private ProcessorInterface $persistProcessor, - private LocalizationService $localizationService, private EntityManagerInterface $entityManager, ) {} @@ -29,8 +29,8 @@ class EntityStateProcessor implements ProcessorInterface */ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) { - if ($data instanceof LocalizedContentInterface) { - return $this->processLocalizedContent($data, $operation, $uriVariables, $context); + if ($this->isLocalizedData($data)) { + return $this->processLocalizedData($data, $operation, $uriVariables, $context); } if ($operation instanceof DeleteOperationInterface) { @@ -40,42 +40,31 @@ class EntityStateProcessor implements ProcessorInterface return $this->persistProcessor->process($data, $operation, $uriVariables, $context); } - private function processLocalizedContent( - LocalizedContentInterface $data, + private function processLocalizedData( + LocalizedEntityInterface $data, Operation $operation, array $uriVariables = [], array $context = [], ): mixed { - $languages = $this->getContextLanguages($context); + $languages = $this->getContentLanguages($context); if ($operation instanceof DeleteOperationInterface) { - if ($languages === null) { - return $this->deleteProcessor->process($data, $operation, $uriVariables, $context); + if ($this->isLocalizedRequest($context)) { + return $this->deleteLocalizedContent($data, $operation, $uriVariables, $context, $languages); } - return $this->deleteLocalizedContent($data, $operation, $uriVariables, $context, $languages); + return $this->deleteProcessor->process($data, $operation, $uriVariables, $context); } - $language = $languages ? $languages[0] : $this->localizationService->getDefaultLanguage(); + $language = $languages[0] ?? $this->localizationService->getDefaultLanguage(); $data->setTranslatableLocale($language); return $this->persistProcessor->process($data, $operation, $uriVariables, $context); } - private function getContextLanguages(array $context): ?array - { - $tags = $context['request']->headers->get('Content-Language', ''); - - if (empty($tags)) { - return null; - } - - return $this->localizationService->getLanguages($tags); - } - private function deleteLocalizedContent( - LocalizedContentInterface $data, + LocalizedEntityInterface $data, Operation $operation, array $uriVariables = [], array $context = [], diff --git a/src/State/LocalizedStateProcessorTrait.php b/src/State/LocalizedStateProcessorTrait.php new file mode 100644 index 0000000..d5c2a1a --- /dev/null +++ b/src/State/LocalizedStateProcessorTrait.php @@ -0,0 +1,62 @@ +requestStack = $requestStack; + } + + #[Required] + public function setLocalizationService(LocalizationService $localizationService) + { + $this->localizationService = $localizationService; + } + + private function isLocalizedData(mixed $data): bool + { + if ($data instanceof LocalizedEntityInterface) { + return true; + } + + return false; + } + + private function isLocalizedRequest(array $context): bool + { + if (\array_key_exists('request', $context)) { + return $context['request']->headers->has('Content-Language'); + } + + return false; + } + + private function getContentLanguages(array $context): ?array + { + $request = $this->requestStack->getCurrentRequest(); + + if (\array_key_exists('request', $context)) { + $request = $context['request']; + } + + $tags = $request->headers->get('Content-Language'); + + if ($tags === null) { + return null; + } + + return $this->localizationService->getLanguages($tags); + } +}