├── .php-cs-fixer.php ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── psalm-baseline-symfony-64.xml ├── rector.php └── src ├── ApiPlatformMsBundle.php ├── ApiResource ├── ExistenceChecker.php └── ExistenceVerifier.php ├── Collection ├── Collection.php ├── PaginatedCollectionIterator.php └── Pagination.php ├── Controller └── ApiResourceExistenceCheckerAction.php ├── DependencyInjection ├── ApiPlatformMsExtension.php ├── Compiler │ └── CreateHttpClientsPass.php └── Configuration.php ├── Dto ├── ApiResourceDtoInterface.php ├── ApiResourceDtoTrait.php ├── ApiResourceExistenceCheckerPayload.php └── ApiResourceExistenceCheckerView.php ├── Event └── RequestEvent.php ├── EventListener └── RequestLoggerListener.php ├── Exception ├── CollectionNotIterableException.php ├── MicroserviceConfigurationException.php ├── MicroserviceNotConfiguredException.php └── ResourceValidationException.php ├── HttpClient ├── AuthenticationHeaderProviderInterface.php ├── GenericHttpClient.php ├── MicroserviceHttpClient.php ├── MicroserviceHttpClientInterface.php ├── ReplaceableHttpClientInterface.php └── ReplaceableHttpClientTrait.php ├── HttpRepository └── AbstractMicroserviceHttpRepository.php ├── Microservice ├── Microservice.php └── MicroservicePool.php ├── Resources └── config │ ├── hal.php │ ├── hydra.php │ ├── jsonapi.php │ ├── routes.xml │ └── services.php ├── Routing └── RouteLoader.php ├── Serializer ├── AbstractApiResourceDenormalizer.php ├── AbstractCollectionDenormalizer.php ├── AbstractConstraintViolationListDenormalizer.php ├── Hal │ ├── ApiResourceDenormalizer.php │ ├── CollectionDenormalizer.php │ ├── ConstraintViolationListDenormalizer.php │ ├── HalDenormalizerTrait.php │ └── ObjectDenormalizer.php ├── Hydra │ ├── ApiResourceDenormalizer.php │ ├── CollectionDenormalizer.php │ ├── ConstraintViolationListDenormalizer.php │ └── HydraDenormalizerTrait.php ├── JsonApi │ ├── ApiResourceDenormalizer.php │ ├── CollectionDenormalizer.php │ ├── ConstraintViolationListDenormalizer.php │ ├── JsonApiDenormalizerTrait.php │ └── ObjectDenormalizer.php └── NameConverterAwareTrait.php └── Validator ├── ApiResourceExist.php ├── ApiResourceExistValidator.php ├── ApiResourceExists.php ├── ApiResourceExistsValidator.php ├── FormatEnabled.php └── FormatEnabledValidator.php /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | in(__DIR__.'/{src,tests}') 7 | ; 8 | 9 | $config = new PhpCsFixer\Config(); 10 | 11 | return $config 12 | ->setParallelConfig(ParallelConfigFactory::detect()) 13 | ->setRules([ 14 | '@Symfony' => true, 15 | 'concat_space' => ['spacing' => 'none'], 16 | 'multiline_whitespace_before_semicolons' => ['strategy' => 'new_line_for_chained_calls'], 17 | 'array_syntax' => ['syntax' => 'short'], 18 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 19 | 'nullable_type_declaration_for_default_null_value' => false, 20 | ]) 21 | ->setRiskyAllowed(true) 22 | ->setLineEnding("\n") 23 | ->setFinder($finder) 24 | ->setCacheFile(__DIR__.'/.php-cs-fixer.cache') 25 | ; 26 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [1.0.0] - 2023-04-05 9 | * Added PHP 8.1 support 10 | * Added API Platform 3 support 11 | * Dropped API Platform < 3.0.12 support 12 | * Dropped Symfony < 6.1 support 13 | * Dropped PHP7 support 14 | 15 | ## [0.4.0] - 2022-01-07 16 | * Removed deprecated `PaginatedCollectionIterator::iterateOver` 17 | * Removed deprecated `findOneByIri`, `findOneBy`, `findBy`, `findAll` methods in `AbstractMicroserviceHttpRepository` 18 | * Added dispatching of a `RequestEvent` once a request is sent 19 | * Added `partialUpdate` method in `AbstractMicroserviceHttpRepository` 20 | * Added `log_request` option to log each request, by default request are logged if debugging is enabled. 21 | 22 | ## [0.3.0] - 2021-01-19 23 | * Added authentication header providers 24 | * Added PHP8 support 25 | * Deprecated `findOneByIri`, `findOneBy`, `findBy`, `findAll` methods in `AbstractMicroserviceHttpRepository` 26 | * Added `fetchOneByIri`, `fetchOneBy`, `fetchBy`, `fetchAll` methods in `AbstractMicroserviceHttpRepository` 27 | * Deprecated `PaginatedCollectionIterator::iterateOver` 28 | * Added `PaginatedCollectionIterator::iterateItems` 29 | * Added `PaginatedCollectionIterator::iteratePages` 30 | 31 | ## [0.2.1] - 2020-10-19 32 | * Fixed `PaginatedCollectionIterator` service definition 33 | 34 | ## [0.2.0] - 2020-10-15 35 | * Excluded unneeded files from export 36 | * Loaded serialization configuration files only when specified in a microservice config 37 | * Moved services definition from XML to PHP 38 | * Added classes to preload 39 | * Added `create`, `update` and `delete` methods in `AbstractHttpMicroserviceRepository` 40 | * Allowed to replace nested Symfony HttpClient during runtime thanks to `setWrappedHttpClient` 41 | * Removed useless `api_platform_ms.http_repository.http_repository` service definition 42 | * Added query params to `AbstractHttpMicroserviceRepository` 43 | * Added nested resource denormalization 44 | * Set lowest `symfony/property-access` version to 4.4 45 | * Replaced Travis CI by Github actions 46 | * Removed PHPCPD QA checks 47 | 48 | ## [0.1.1] - 2020-05-12 49 | * Denormalized objects using APIP denormalizers (except for HAL because APIP doesn't handle it) 50 | 51 | ## [0.1.0] - 2020-05-10 52 | * Added microservice specific HTTP client 53 | * Added generic HTTP client 54 | * Added Abstract HTTP repository 55 | * Added collection, pagination and paginated collection iterator 56 | * Added ConstraintViolationList denormalizers 57 | * Added Collection denormalizers 58 | * Added ApiResource denormalizers 59 | * Added Object denormalizers 60 | * Added `ApiResourceExist` constraint 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Mathias Arlaud 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # API Platform Microservice Bundle 2 | 3 | ![Packagist](https://img.shields.io/packagist/v/mtarld/api-platform-ms-bundle.svg) 4 | ![GitHub](https://img.shields.io/github/license/mtarld/api-platform-ms-bundle.svg) 5 | [![Actions Status](https://github.com/mtarld/symbok-bundle/workflows/CI/badge.svg)](https://github.com/mtarld/api-platform-ms-bundle/actions) 6 | 7 | A Microservice tools bundle for API Platform. 8 | 9 | ## A microservice bundle ? 10 | In a microservice context where each microservices are API Platform instances, 11 | each instance must behave as an API Platform data producer and an 12 | API Platform data consumer. 13 | 14 | But API Platform isn't intended to behave like a client. 15 | 16 | Therefore, here comes the API Platform Microservice Bundle! 17 | 18 | This bundle intents to provide a set of [tools](#provided-tools) 19 | to ease the development of client behaving microservices by trying to abstract the http call layer. 20 | 21 | ## Getting started 22 | ### Installation 23 | You can easily install API Platform Microservice bundle by composer 24 | ``` 25 | $ composer require mtarld/api-platform-ms-bundle 26 | ``` 27 | Then, bundle should be registered. Just verify that `config\bundles.php` is containing : 28 | ```php 29 | Mtarld\ApiPlatformMsBundle\ApiPlatformMsBundle::class => ['all' => true], 30 | ``` 31 | 32 | ### Configuration 33 | Once the bundle is installed, you should configure it to fit your needs. 34 | 35 | To do so, edit `config/packages/api_platform_ms.yaml` and `config/routes/api_platform_ms.yaml` 36 | ```yaml 37 | # config/packages/api_platform_ms.yaml 38 | 39 | api_platform_ms: 40 | # HttpClient that will be used internally (default: 'Symfony\Contracts\HttpClient\HttpClientInterface') 41 | http_client: ~ 42 | 43 | # Name of the current microservice (required) 44 | name: client 45 | 46 | # Option to log request (default '%kernel.debug%') 47 | log_request: true 48 | 49 | # Host used for microservice dynamic routes generation (default: []) 50 | hosts: 51 | - https://client.api 52 | 53 | # List of related microservices 54 | microservices: 55 | # Microservice name 56 | product: 57 | # Microservice base uri (required) 58 | base_uri: https://product.api 59 | 60 | # Microservice API path (default: '') 61 | api_path: /api 62 | 63 | # Microservice format (required) 64 | # Supported formats: jsonld, jsonhal, jsonapi 65 | format: jsonld 66 | ``` 67 | ```yaml 68 | # config/routes/api_platform_ms.yaml 69 | 70 | api_platform_ms: 71 | resource: '@ApiPlatformMsBundle/Resources/config/routes.xml' 72 | prefix: /api 73 | ``` 74 | And you're ready to go ! :rocket: 75 | 76 | ## Provided tools 77 | - [**API resource existence constraint**](src/Resources/doc/tools/existence-constraint.md): 78 | Help you to ensure that the related resources are existing on the other microservice when doing validation. 79 | 80 | - [**Resource collection and pagination**](src/Resources/doc/tools/pagination.md): 81 | Handy objects and services to ease paginated collection iteration. 82 | 83 | - [**HTTP repository**](src/Resources/doc/tools/http-repository.md): 84 | An extendable HTTP repository that you could configure to fetch resources. 85 | 86 | - [**HTTP client wrappers**](src/Resources/doc/tools/http-wrapper.md): 87 | Clients that adapt HTTP calls according to the targeted microservice configuration. 88 | 89 | - [**Authentication header providers**](src/Resources/doc/tools/authentication-header-provider.md): 90 | Providers that dynamically inject authentication headers in requests. 91 | 92 | - [**ConstraintViolation list denormalizer**](src/Resources/doc/tools/constraint-violation-list.md): 93 | Allows you to create a `ConstraintViolationList` instance from a serialized string. 94 | 95 | - [**Extension points**](src/Resources/doc/tools/extension-points.md): 96 | Events that you can listen to extend the bundle behavior. 97 | 98 | ## Supported microservice formats 99 | Currently, API Platform supported formats are: 100 | - jsonld 101 | - jsonapi 102 | - jsonhal 103 | 104 | ## Contributing 105 | Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests to us. 106 | 107 | After writing your fix/feature, you can run following commands to make sure that everything is still ok. 108 | 109 | ```bash 110 | # Install dev dependencies 111 | $ composer install 112 | 113 | # Running tests locally 114 | $ make test 115 | ``` 116 | 117 | ## Authors 118 | - Mathias Arlaud - [mtarld](https://github.com/mtarld) - 119 | 120 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mtarld/api-platform-ms-bundle", 3 | "type": "symfony-bundle", 4 | "description": "API Platform Microservice Bundle", 5 | "keywords": [ 6 | "symfony", 7 | "bundle", 8 | "symfony-bundle", 9 | "api-plaform", 10 | "microservice", 11 | "api" 12 | ], 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Mathias Arlaud", 17 | "email": "mathias.arlaud@gmail.com" 18 | } 19 | ], 20 | "require": { 21 | "php": "^8.1", 22 | "api-platform/json-api": "^3.4.9|^4.0", 23 | "api-platform/json-hal": "^3.4.9|^4.0", 24 | "api-platform/jsonld": "^3.4.9|^4.0", 25 | "api-platform/symfony": "^3.4.9|^4.0", 26 | "psr/log": "^1.0|^2.0|^3.0", 27 | "symfony/event-dispatcher": "^6.4|^7.0", 28 | "symfony/http-client": "^6.4|^7.0", 29 | "symfony/http-foundation": "^6.4|^7.0", 30 | "symfony/serializer": "^6.4|^7.0", 31 | "symfony/validator": "^6.4|^7.0" 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "Mtarld\\ApiPlatformMsBundle\\": "src/" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "Mtarld\\ApiPlatformMsBundle\\Tests\\": "tests/" 41 | } 42 | }, 43 | "require-dev": { 44 | "doctrine/inflector": "^2.0", 45 | "friendsofphp/php-cs-fixer": "^3.1", 46 | "phpmd/phpmd": "^2.8", 47 | "phpunit/phpunit": "^9.6.22", 48 | "psalm/plugin-phpunit": "^0.19", 49 | "rector/rector": "^2.0", 50 | "symfony/browser-kit": "^6.4|^7.0", 51 | "symfony/framework-bundle": "^6.4|^7.0", 52 | "symfony/phpunit-bridge": "^6.4|^7.0", 53 | "symfony/yaml": "^6.4|^7.0", 54 | "vimeo/psalm": "^6.0", 55 | "willdurand/negotiation": "^3.0" 56 | }, 57 | "config": { 58 | "sort-packages": true, 59 | "allow-plugins": { 60 | "composer/package-versions-deprecated": true 61 | } 62 | }, 63 | "extra": { 64 | "branch-alias": { 65 | "dev-master": "1.x-dev", 66 | "dev-main": "1.x-dev" 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /psalm-baseline-symfony-64.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPaths([ 9 | __DIR__.'/src', 10 | __DIR__.'/tests', 11 | ]) 12 | 13 | ->withPhpSets(php81: true) 14 | ->withTypeCoverageLevel(0) 15 | ->withDeadCodeLevel(0) 16 | ->withCodeQualityLevel(0) 17 | ; 18 | -------------------------------------------------------------------------------- /src/ApiPlatformMsBundle.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class ApiPlatformMsBundle extends Bundle 18 | { 19 | #[\Override] 20 | public function build(ContainerBuilder $container): void 21 | { 22 | parent::build($container); 23 | 24 | $container->addCompilerPass(new CreateHttpClientsPass()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/ApiResource/ExistenceChecker.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class ExistenceChecker implements ReplaceableHttpClientInterface 24 | { 25 | use ReplaceableHttpClientTrait; 26 | 27 | private $httpClient; 28 | 29 | public function __construct( 30 | GenericHttpClient $httpClient, 31 | private readonly SerializerInterface $serializer, 32 | private readonly MicroservicePool $microservices, 33 | ) { 34 | $this->httpClient = $httpClient; 35 | 36 | trigger_deprecation('mtarld/api-platform-ms-bundle', '1.3.0', sprintf('%s is deprecated.', self::class)); 37 | } 38 | 39 | /** 40 | * @param list $iris 41 | * 42 | * @return array 43 | * 44 | * @throws ExceptionInterface 45 | */ 46 | public function getExistenceStatuses(string $microserviceName, array $iris): array 47 | { 48 | if (empty($iris)) { 49 | return []; 50 | } 51 | 52 | $microservice = $this->microservices->get($microserviceName); 53 | 54 | $response = $this->httpClient->request( 55 | $microservice, 56 | 'POST', 57 | sprintf('/%s_check_resource', $microserviceName), 58 | new ApiResourceExistenceCheckerPayload($iris), 59 | 'application/json', 60 | 'json' 61 | ); 62 | 63 | /** @var ApiResourceExistenceCheckerView $checkedIris */ 64 | $checkedIris = $this->serializer->deserialize($response->getContent(), ApiResourceExistenceCheckerView::class, 'json'); 65 | 66 | return $checkedIris->existences; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/ApiResource/ExistenceVerifier.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class ExistenceVerifier implements ReplaceableHttpClientInterface 16 | { 17 | use ReplaceableHttpClientTrait; 18 | 19 | public function __construct( 20 | private GenericHttpClient $httpClient, 21 | private readonly MicroservicePool $microservices, 22 | ) { 23 | } 24 | 25 | public function verify(string $microserviceName, string $iri): bool 26 | { 27 | $response = $this->httpClient->request( 28 | $this->microservices->get($microserviceName), 29 | 'GET', 30 | $iri, 31 | ); 32 | 33 | $statusCode = $response->getStatusCode(); 34 | 35 | if (200 <= $statusCode && 300 > $statusCode) { 36 | return true; 37 | } 38 | 39 | if (404 === $statusCode) { 40 | return false; 41 | } 42 | 43 | // make it throw 44 | return (bool) $response->getContent(true); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Collection/Collection.php: -------------------------------------------------------------------------------- 1 | 17 | * 18 | * @author Mathias Arlaud 19 | */ 20 | class Collection implements \IteratorAggregate, \Countable 21 | { 22 | private ?Microservice $microservice = null; 23 | 24 | /** 25 | * @param list $elements 26 | */ 27 | public function __construct( 28 | private readonly array $elements, 29 | private readonly int $count, 30 | private readonly ?Pagination $pagination = null, 31 | ) { 32 | } 33 | 34 | public function getPagination(): ?Pagination 35 | { 36 | return $this->pagination; 37 | } 38 | 39 | public function hasPagination(): bool 40 | { 41 | return $this->pagination instanceof Pagination; 42 | } 43 | 44 | /** 45 | * @return \Iterator 46 | */ 47 | #[\Override] 48 | public function getIterator(): \Iterator 49 | { 50 | return new \ArrayIterator($this->elements); 51 | } 52 | 53 | #[\Override] 54 | public function count(): int 55 | { 56 | return $this->count; 57 | } 58 | 59 | /** 60 | * @internal 61 | * 62 | * @return Collection 63 | */ 64 | public function withMicroservice(Microservice $microservice): self 65 | { 66 | $collection = clone $this; 67 | $collection->microservice = $microservice; 68 | 69 | return $collection; 70 | } 71 | 72 | /** 73 | * @internal 74 | */ 75 | public function getMicroservice(): ?Microservice 76 | { 77 | return $this->microservice; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Collection/PaginatedCollectionIterator.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | class PaginatedCollectionIterator implements ReplaceableHttpClientInterface 25 | { 26 | use ReplaceableHttpClientTrait; 27 | 28 | private $httpClient; 29 | 30 | public function __construct(GenericHttpClient $httpClient, private readonly SerializerInterface $serializer) 31 | { 32 | $this->httpClient = $httpClient; 33 | } 34 | 35 | /** 36 | * @param Collection $collection 37 | * 38 | * @return \Iterator<\Iterator> 39 | * 40 | * @throws ExceptionInterface 41 | */ 42 | public function iteratePages(Collection $collection): \Iterator 43 | { 44 | if (null === $collection->getMicroservice()) { 45 | throw new CollectionNotIterableException("Collection isn't iterable because it doesn't hold microservice metadata"); 46 | } 47 | 48 | yield $collection; 49 | 50 | if (null === $pagination = $collection->getPagination()) { 51 | return; 52 | } 53 | 54 | if (null === $nextPage = $pagination->getNext()) { 55 | return; 56 | } 57 | 58 | $nextPart = $this->getNextCollectionPart($collection, $nextPage); 59 | 60 | yield from $this->iteratePages($nextPart); 61 | } 62 | 63 | /** 64 | * @param Collection $collection 65 | * 66 | * @return \Iterator 67 | * 68 | * @throws ExceptionInterface 69 | */ 70 | public function iterateItems(Collection $collection): \Iterator 71 | { 72 | foreach ($this->iteratePages($collection) as $page) { 73 | yield from $page; 74 | } 75 | } 76 | 77 | /** 78 | * @return Collection 79 | * 80 | * @throws ExceptionInterface 81 | */ 82 | private function getNextCollectionPart(Collection $collection, string $nextPage): Collection 83 | { 84 | /** @var Microservice $microservice */ 85 | $microservice = $collection->getMicroservice(); 86 | 87 | /** @var Collection $nextPart */ 88 | $nextPart = $this->serializer->deserialize( 89 | $this->httpClient->request($microservice, 'GET', $nextPage)->getContent(), 90 | sprintf('%s<%s>', Collection::class, $collection->getIterator()->current()::class), 91 | $microservice->getFormat() 92 | ); 93 | 94 | return $nextPart->withMicroservice($microservice); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Collection/Pagination.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class Pagination 13 | { 14 | public function __construct( 15 | private readonly string $current, 16 | private readonly string $first, 17 | private readonly string $last, 18 | private readonly ?string $previous, 19 | private readonly ?string $next, 20 | ) { 21 | } 22 | 23 | public function getCurrent(): string 24 | { 25 | return $this->current; 26 | } 27 | 28 | public function getFirst(): string 29 | { 30 | return $this->first; 31 | } 32 | 33 | public function getLast(): string 34 | { 35 | return $this->last; 36 | } 37 | 38 | public function getPrevious(): ?string 39 | { 40 | return $this->previous; 41 | } 42 | 43 | public function getNext(): ?string 44 | { 45 | return $this->next; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Controller/ApiResourceExistenceCheckerAction.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | class ApiResourceExistenceCheckerAction 26 | { 27 | public function __construct( 28 | private readonly SerializerInterface $serializer, 29 | private readonly IriConverterInterface $iriConverter, 30 | private readonly ValidatorInterface $validator, 31 | ) { 32 | } 33 | 34 | public function __invoke(Request $request): JsonResponse 35 | { 36 | $contentType = $request->getContentTypeFormat(); 37 | 38 | if (null === $contentType) { 39 | throw new BadRequestHttpException('Content type is not supported'); 40 | } 41 | 42 | /** @var string $content */ 43 | if (empty($content = $request->getContent())) { 44 | throw new BadRequestHttpException('Content is missing'); 45 | } 46 | 47 | try { 48 | /** @var ApiResourceExistenceCheckerPayload $payload */ 49 | $payload = $this->serializer->deserialize($content, ApiResourceExistenceCheckerPayload::class, $contentType); 50 | } catch (\Throwable $e) { 51 | throw new BadRequestHttpException($e->getMessage()); 52 | } 53 | 54 | $this->validator->validate($payload); 55 | 56 | $existences = []; 57 | foreach ($payload->iris as $iri) { 58 | $existences[$iri] = $this->isIriValid($iri); 59 | } 60 | 61 | return new JsonResponse(new ApiResourceExistenceCheckerView($existences)); 62 | } 63 | 64 | private function isIriValid(string $iri): bool 65 | { 66 | try { 67 | $this->iriConverter->getResourceFromIri($iri); 68 | } catch (\Throwable) { 69 | return false; 70 | } 71 | 72 | return true; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/DependencyInjection/ApiPlatformMsExtension.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class ApiPlatformMsExtension extends Extension 23 | { 24 | final public const FORMAT_CONFIGURATION_FILE_MAPPING = [ 25 | 'jsonld' => 'hydra.php', 26 | 'jsonapi' => 'jsonapi.php', 27 | 'jsonhal' => 'hal.php', 28 | ]; 29 | 30 | #[\Override] 31 | public function getAlias(): string 32 | { 33 | return 'api_platform_ms'; 34 | } 35 | 36 | /** 37 | * @param array $configs 38 | */ 39 | #[\Override] 40 | public function load(array $configs, ContainerBuilder $container): void 41 | { 42 | $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 43 | $loader->load('services.php'); 44 | 45 | if (null === $configuration = $this->getConfiguration($configs, $container)) { 46 | return; 47 | } 48 | 49 | $config = $this->processConfiguration($configuration, $configs); 50 | 51 | $formats = array_values( 52 | array_unique( 53 | array_map( 54 | static fn (array $microservice): string => $microservice['format'], 55 | array_filter($config['microservices'], 56 | static fn (array $microservice): bool => isset(self::FORMAT_CONFIGURATION_FILE_MAPPING[$microservice['format']]) 57 | ) 58 | ) 59 | ) 60 | ); 61 | 62 | foreach ($formats as $format) { 63 | $loader->load(self::FORMAT_CONFIGURATION_FILE_MAPPING[$format]); 64 | } 65 | 66 | $container->setAlias('api_platform_ms.http_client', $config['http_client']); 67 | 68 | $container->setParameter('api_platform_ms.name', $config['name']); 69 | $container->setParameter('api_platform_ms.hosts', $config['hosts']); 70 | $container->setParameter('api_platform_ms.microservices', $config['microservices']); 71 | $container->setParameter('api_platform_ms.enabled_formats', $formats); 72 | 73 | $container->registerForAutoconfiguration(AuthenticationHeaderProviderInterface::class)->addTag('api_platform_ms.authentication_header_provider'); 74 | 75 | if (!$config['log_request']) { 76 | $container->removeDefinition('api_platform_ms.request_logger_listener'); 77 | } 78 | } 79 | 80 | /** 81 | * @SuppressWarnings(UnusedFormalParameter) 82 | */ 83 | #[\Override] 84 | public function getConfiguration(array $config, ContainerBuilder $container): ?ConfigurationInterface 85 | { 86 | /** @var bool $debug */ 87 | $debug = $container->getParameter('kernel.debug'); 88 | 89 | return new Configuration($debug); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/CreateHttpClientsPass.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class CreateHttpClientsPass implements CompilerPassInterface 17 | { 18 | #[\Override] 19 | public function process(ContainerBuilder $container): void 20 | { 21 | /** @var array $microservices */ 22 | $microservices = $container->getParameter('api_platform_ms.microservices'); 23 | 24 | foreach (array_keys($microservices) as $microserviceName) { 25 | $container->setDefinition( 26 | sprintf('api_platform_ms.http_client.microservice.%s', $microserviceName), 27 | (new Definition(MicroserviceHttpClient::class, [ 28 | $container->getDefinition('api_platform_ms.http_client.generic'), 29 | $container->getDefinition('api_platform_ms.microservice_pool'), 30 | $microserviceName, 31 | ]))->setPublic(true) 32 | ); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class Configuration implements ConfigurationInterface 15 | { 16 | /** 17 | * @param bool $debug Whether debugging is enabled or not 18 | */ 19 | public function __construct(private readonly bool $debug) 20 | { 21 | } 22 | 23 | #[\Override] 24 | public function getConfigTreeBuilder(): TreeBuilder 25 | { 26 | $builder = new TreeBuilder('api_platform_ms'); 27 | $builder->getRootNode() 28 | ->children() 29 | ->scalarNode('http_client')->defaultValue(HttpClientInterface::class)->treatNullLike(HttpClientInterface::class)->end() 30 | ->scalarNode('name')->isRequired()->end() 31 | ->scalarNode('log_request')->defaultValue($this->debug)->end() 32 | ->arrayNode('hosts')->variablePrototype()->end()->end() 33 | ->arrayNode('microservices') 34 | ->useAttributeAsKey('name') 35 | ->arrayPrototype() 36 | ->children() 37 | ->scalarNode('base_uri')->isRequired()->end() 38 | ->scalarNode('api_path')->end() 39 | ->enumNode('format')->values(array_keys(ApiPlatformMsExtension::FORMAT_CONFIGURATION_FILE_MAPPING))->isRequired()->end() 40 | ->end() 41 | ->end() 42 | ->end() 43 | ->end() 44 | ; 45 | 46 | return $builder; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Dto/ApiResourceDtoInterface.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | interface ApiResourceDtoInterface 9 | { 10 | public function getIri(): ?string; 11 | 12 | public function setIri(string $iri): void; 13 | } 14 | -------------------------------------------------------------------------------- /src/Dto/ApiResourceDtoTrait.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | trait ApiResourceDtoTrait 9 | { 10 | /** 11 | * @var string|null 12 | */ 13 | protected $iri; 14 | 15 | public function getIri(): ?string 16 | { 17 | return $this->iri; 18 | } 19 | 20 | public function setIri(string $iri): void 21 | { 22 | $this->iri = $iri; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Dto/ApiResourceExistenceCheckerPayload.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class ApiResourceExistenceCheckerPayload 13 | { 14 | /** 15 | * @param array $iris 16 | */ 17 | public function __construct( 18 | #[Assert\All([ 19 | new Assert\Type('string'), 20 | new Assert\NotBlank(allowNull: false), 21 | ])] 22 | public array $iris, 23 | ) { 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Dto/ApiResourceExistenceCheckerView.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class ApiResourceExistenceCheckerView 11 | { 12 | /** 13 | * @param array $existences 14 | */ 15 | public function __construct(public readonly array $existences) 16 | { 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Event/RequestEvent.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class RequestEvent 15 | { 16 | public function __construct( 17 | private readonly Microservice $microservice, 18 | private readonly string $method, 19 | private readonly string $uri, 20 | private readonly array $options, 21 | ) { 22 | } 23 | 24 | public function getMicroservice(): Microservice 25 | { 26 | return $this->microservice; 27 | } 28 | 29 | public function getMethod(): string 30 | { 31 | return $this->method; 32 | } 33 | 34 | public function getUri(): string 35 | { 36 | return $this->uri; 37 | } 38 | 39 | public function getOptions(): array 40 | { 41 | return $this->options; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/EventListener/RequestLoggerListener.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class RequestLoggerListener 14 | { 15 | public function __construct(private readonly LoggerInterface $logger) 16 | { 17 | } 18 | 19 | public function __invoke(RequestEvent $event): void 20 | { 21 | $this->logger->debug('Calling "{microservice_name}" microservice: "{method} {url}".', [ 22 | 'method' => $event->getMethod(), 23 | 'microservice_name' => $event->getMicroservice()->getName(), 24 | 'url' => $event->getUri(), 25 | ]); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Exception/CollectionNotIterableException.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class CollectionNotIterableException extends \LogicException 9 | { 10 | } 11 | -------------------------------------------------------------------------------- /src/Exception/MicroserviceConfigurationException.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class MicroserviceConfigurationException extends \LogicException 9 | { 10 | public function __construct(string $name, string $message) 11 | { 12 | parent::__construct(sprintf("'%s' microservice is wrongly configured. %s", $name, $message), 0, null); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Exception/MicroserviceNotConfiguredException.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class MicroserviceNotConfiguredException extends \LogicException 9 | { 10 | public function __construct(string $name) 11 | { 12 | parent::__construct( 13 | sprintf("'%s' microservice configuration wasn't found. Please configure it under api_platform_ms.microservices configuration key", $name), 14 | 0, 15 | null 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Exception/ResourceValidationException.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class ResourceValidationException extends RuntimeException 12 | { 13 | public function __construct(private readonly mixed $value, private readonly ConstraintViolationList $violations) 14 | { 15 | parent::__construct((string) $violations); 16 | } 17 | 18 | public function getValue(): mixed 19 | { 20 | return $this->value; 21 | } 22 | 23 | public function getViolations(): ConstraintViolationList 24 | { 25 | return $this->violations; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/HttpClient/AuthenticationHeaderProviderInterface.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | interface AuthenticationHeaderProviderInterface 9 | { 10 | public function getHeader(): string; 11 | 12 | public function getValue(): string; 13 | 14 | /** 15 | * @param array $context 16 | */ 17 | public function supports(array $context): bool; 18 | } 19 | -------------------------------------------------------------------------------- /src/HttpClient/GenericHttpClient.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | class GenericHttpClient implements ReplaceableHttpClientInterface 22 | { 23 | private const MIME_TYPES = [ 24 | 'jsonld' => 'application/ld+json', 25 | 'jsonapi' => 'application/vnd.api+json', 26 | 'jsonhal' => 'application/hal+json', 27 | ]; 28 | 29 | private const PATCH_MIME_TYPES = [ 30 | 'jsonld' => 'application/merge-patch+json', 31 | 'jsonapi' => 'application/vnd.api+json', 32 | 'jsonhal' => 'application/merge-patch+json', 33 | ]; 34 | 35 | /** 36 | * @param list $authenticationHeaderProviders 37 | */ 38 | public function __construct( 39 | private readonly SerializerInterface $serializer, 40 | private HttpClientInterface $httpClient, 41 | private readonly iterable $authenticationHeaderProviders = [], 42 | private readonly ?EventDispatcherInterface $dispatcher = null, 43 | ) { 44 | } 45 | 46 | /** 47 | * @throws ExceptionInterface 48 | */ 49 | public function request(Microservice $microservice, string $method, string $uri, mixed $body = null, ?string $mimeType = null, ?string $bodyFormat = null): ResponseInterface 50 | { 51 | $mimeType ??= self::MIME_TYPES[$microservice->getFormat()]; 52 | $contentType = 'PATCH' === $method ? self::PATCH_MIME_TYPES[$microservice->getFormat()] : $mimeType; 53 | $bodyFormat ??= $microservice->getFormat(); 54 | 55 | $options = [ 56 | 'base_uri' => $microservice->getBaseUri(), 57 | 'headers' => [ 58 | 'Content-Type' => $contentType, 59 | 'Accept' => $mimeType, 60 | ], 61 | ]; 62 | 63 | $uri = preg_replace('/^\/?'.preg_quote($microservice->getApiPath(), '/').'/', '', $uri); 64 | $uri = rtrim($microservice->getApiPath(), '/').'/'.ltrim((string) $uri, '/'); 65 | 66 | if (null !== $body) { 67 | $options['body'] = $this->serializer->serialize($body, $bodyFormat); 68 | } 69 | 70 | // Only the first authentication header provider that supports the context will add the authentication header. 71 | foreach ($this->authenticationHeaderProviders as $provider) { 72 | if ($provider->supports(['method' => $method, 'uri' => $uri, 'options' => $options, 'microservice' => $microservice])) { 73 | $options['headers'] += [$provider->getHeader() => $provider->getValue()]; 74 | 75 | break; 76 | } 77 | } 78 | 79 | $response = $this->httpClient->request($method, $uri, $options); 80 | 81 | if (null !== $this->dispatcher) { 82 | $this->dispatcher->dispatch(new RequestEvent($microservice, $method, $uri, $options)); 83 | } 84 | 85 | return $response; 86 | } 87 | 88 | #[\Override] 89 | public function setWrappedHttpClient(HttpClientInterface $httpClient): void 90 | { 91 | $this->httpClient = $httpClient; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/HttpClient/MicroserviceHttpClient.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class MicroserviceHttpClient implements MicroserviceHttpClientInterface 14 | { 15 | use ReplaceableHttpClientTrait; 16 | 17 | private $httpClient; 18 | 19 | public function __construct( 20 | GenericHttpClient $httpClient, 21 | private readonly MicroservicePool $microservices, 22 | private readonly string $microserviceName, 23 | ) { 24 | $this->httpClient = $httpClient; 25 | } 26 | 27 | #[\Override] 28 | public function request(string $method, string $uri, mixed $body = null, ?string $mimeType = null, ?string $bodyFormat = null): ResponseInterface 29 | { 30 | return $this->httpClient->request($this->microservices->get($this->microserviceName), $method, $uri, $body, $mimeType, $bodyFormat); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/HttpClient/MicroserviceHttpClientInterface.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | interface MicroserviceHttpClientInterface extends ReplaceableHttpClientInterface 12 | { 13 | /** 14 | * @throws HttpExceptionInterface 15 | */ 16 | public function request(string $method, string $uri, mixed $body = null, ?string $mimeType = null, ?string $bodyFormat = null): ResponseInterface; 17 | } 18 | -------------------------------------------------------------------------------- /src/HttpClient/ReplaceableHttpClientInterface.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | interface ReplaceableHttpClientInterface 11 | { 12 | public function setWrappedHttpClient(HttpClientInterface $httpClient): void; 13 | } 14 | -------------------------------------------------------------------------------- /src/HttpClient/ReplaceableHttpClientTrait.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | trait ReplaceableHttpClientTrait 13 | { 14 | public function setWrappedHttpClient(HttpClientInterface $httpClient): void 15 | { 16 | $this->httpClient->setWrappedHttpClient($httpClient); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/HttpRepository/AbstractMicroserviceHttpRepository.php: -------------------------------------------------------------------------------- 1 | 37 | */ 38 | abstract class AbstractMicroserviceHttpRepository implements ReplaceableHttpClientInterface 39 | { 40 | use ReplaceableHttpClientTrait; 41 | 42 | protected $serializer; 43 | protected $httpClient; 44 | 45 | public function __construct( 46 | GenericHttpClient $httpClient, 47 | SerializerInterface $serializer, 48 | private readonly MicroservicePool $microservices, 49 | ) { 50 | $this->httpClient = $httpClient; 51 | $this->serializer = $serializer; 52 | } 53 | 54 | abstract protected function getMicroserviceName(): string; 55 | 56 | abstract protected function getResourceEndpoint(): string; 57 | 58 | abstract protected function getResourceDto(): string; 59 | 60 | /** 61 | * @param array $additionalQueryParams 62 | * 63 | * @throws ExceptionInterface 64 | */ 65 | public function fetchOneByIri(string $iri, array $additionalQueryParams = []): ?ApiResourceDtoInterface 66 | { 67 | try { 68 | return $this->serializer->deserialize( 69 | $this->request('GET', $this->buildUri($iri, $additionalQueryParams))->getContent(), 70 | $this->getResourceDto(), 71 | $this->getMicroservice()->getFormat() 72 | ); 73 | } catch (ClientExceptionInterface $e) { 74 | if (404 === $e->getCode()) { 75 | return null; 76 | } 77 | 78 | throw $e; 79 | } 80 | } 81 | 82 | /** 83 | * @param array $criteria 84 | * @param array $additionalQueryParams 85 | * 86 | * @throws ExceptionInterface 87 | */ 88 | public function fetchOneBy(array $criteria, array $additionalQueryParams = []): ?ApiResourceDtoInterface 89 | { 90 | $items = iterator_to_array($this->fetchBy($criteria, $additionalQueryParams)); 91 | $item = reset($items); 92 | 93 | return false !== $item ? $item : null; 94 | } 95 | 96 | /** 97 | * @param array $criteria 98 | * @param array $additionalQueryParams 99 | * 100 | * @return Collection 101 | * 102 | * @throws ExceptionInterface 103 | */ 104 | public function fetchBy(array $criteria, array $additionalQueryParams = []): Collection 105 | { 106 | return $this->requestCollection($criteria + $additionalQueryParams); 107 | } 108 | 109 | /** 110 | * @param array $additionalQueryParams 111 | * 112 | * @return Collection 113 | * 114 | * @throws ExceptionInterface 115 | */ 116 | public function fetchAll(array $additionalQueryParams = []): Collection 117 | { 118 | return $this->requestCollection($additionalQueryParams); 119 | } 120 | 121 | /** 122 | * @param array $additionalQueryParams 123 | * 124 | * @throws ResourceValidationException 125 | * @throws ExceptionInterface 126 | */ 127 | public function create(ApiResourceDtoInterface $resource, array $additionalQueryParams = []): ApiResourceDtoInterface 128 | { 129 | try { 130 | $response = $this->request('POST', $this->buildUri($this->getResourceEndpoint(), $additionalQueryParams), $resource, null, 'json'); 131 | 132 | return $this->serializer->deserialize($response->getContent(), $this->getResourceDto(), $this->getMicroservice()->getFormat()); 133 | } catch (ClientExceptionInterface $e) { 134 | if ((400 === $e->getCode()) && null !== $violations = $this->createConstraintViolationListFromResponse($e->getResponse())) { 135 | throw new ResourceValidationException($resource, $violations); 136 | } 137 | 138 | throw $e; 139 | } 140 | } 141 | 142 | /** 143 | * Update a resource using PUT verb. 144 | * 145 | * @param array $additionalQueryParams 146 | * 147 | * @throws ResourceValidationException 148 | * @throws ExceptionInterface 149 | * @throws \RuntimeException 150 | */ 151 | public function update(ApiResourceDtoInterface $resource, array $additionalQueryParams = []): ApiResourceDtoInterface 152 | { 153 | if (null === $iri = $resource->getIri()) { 154 | throw new \RuntimeException('Cannot update a resource without iri'); 155 | } 156 | 157 | try { 158 | $response = $this->request('PUT', $this->buildUri($iri, $additionalQueryParams), $resource, null, 'json'); 159 | 160 | return $this->serializer->deserialize($response->getContent(), $this->getResourceDto(), $this->getMicroservice()->getFormat()); 161 | } catch (ClientExceptionInterface $e) { 162 | if ((400 === $e->getCode()) && null !== $violations = $this->createConstraintViolationListFromResponse($e->getResponse())) { 163 | throw new ResourceValidationException($resource, $violations); 164 | } 165 | 166 | throw $e; 167 | } 168 | } 169 | 170 | /** 171 | * Partially update a resource using PATCH verb. 172 | * 173 | * @psalm-param array $additionalQueryParams 174 | * 175 | * @throws ResourceValidationException 176 | * @throws ExceptionInterface 177 | * @throws \RuntimeException 178 | */ 179 | public function partialUpdate(ApiResourceDtoInterface $resource, array $additionalQueryParams = []): ApiResourceDtoInterface 180 | { 181 | if (null === $iri = $resource->getIri()) { 182 | throw new \RuntimeException('Cannot partially update a resource without iri'); 183 | } 184 | 185 | try { 186 | $response = $this->request('PATCH', $this->buildUri($iri, $additionalQueryParams), $resource, null, 'json'); 187 | 188 | return $this->serializer->deserialize($response->getContent(), $this->getResourceDto(), $this->getMicroservice()->getFormat()); 189 | } catch (ClientExceptionInterface $e) { 190 | if ((400 === $e->getCode()) && null !== $violations = $this->createConstraintViolationListFromResponse($e->getResponse())) { 191 | throw new ResourceValidationException($resource, $violations); 192 | } 193 | 194 | throw $e; 195 | } 196 | } 197 | 198 | /** 199 | * @param array $additionalQueryParams 200 | * 201 | * @throws ResourceValidationException 202 | * @throws ExceptionInterface 203 | * @throws \RuntimeException 204 | */ 205 | public function delete(ApiResourceDtoInterface $resource, array $additionalQueryParams = []): void 206 | { 207 | if (null === $iri = $resource->getIri()) { 208 | throw new \RuntimeException('Cannot update a resource without iri'); 209 | } 210 | 211 | $response = $this->request('DELETE', $this->buildUri($iri, $additionalQueryParams)); 212 | $statusCode = $response->getStatusCode(); 213 | 214 | if (500 <= $statusCode) { 215 | throw new ServerException($response); 216 | } 217 | 218 | if (400 <= $statusCode) { 219 | if (null !== $violations = $this->createConstraintViolationListFromResponse($response)) { 220 | throw new ResourceValidationException($resource, $violations); 221 | } 222 | 223 | throw new ClientException($response); 224 | } 225 | 226 | if (300 <= $statusCode) { 227 | throw new RedirectionException($response); 228 | } 229 | } 230 | 231 | /** 232 | * @param array $queryParams 233 | * 234 | * @return Collection 235 | * 236 | * @throws ExceptionInterface 237 | */ 238 | protected function requestCollection(array $queryParams = []): Collection 239 | { 240 | $response = $this->request('GET', $this->buildUri($this->getResourceEndpoint(), $queryParams)); 241 | 242 | /** @var Collection $collection */ 243 | $collection = $this->serializer->deserialize( 244 | $response->getContent(), 245 | Collection::class.'<'.$this->getResourceDto().'>', 246 | $this->getMicroservice()->getFormat() 247 | ); 248 | 249 | return $collection->withMicroservice($this->getMicroservice()); 250 | } 251 | 252 | /** 253 | * @throws ExceptionInterface 254 | */ 255 | protected function request(string $method, string $uri, mixed $body = null, ?string $mimeType = null, ?string $bodyFormat = null): ResponseInterface 256 | { 257 | return $this->httpClient->request($this->getMicroservice(), $method, $uri, $body, $mimeType, $bodyFormat); 258 | } 259 | 260 | private function getMicroservice(): Microservice 261 | { 262 | return $this->microservices->get($this->getMicroserviceName()); 263 | } 264 | 265 | private function createConstraintViolationListFromResponse(ResponseInterface $response): ?ConstraintViolationList 266 | { 267 | try { 268 | return $this->serializer->deserialize($response->getContent(false), ConstraintViolationList::class, $this->getMicroservice()->getFormat()); 269 | } catch (SerializerExceptionInterface) { 270 | return null; 271 | } 272 | } 273 | 274 | private function buildUri(string $uri, array $queryParams): string 275 | { 276 | return [] !== $queryParams ? sprintf('%s?%s', $uri, http_build_query($queryParams)) : $uri; 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /src/Microservice/Microservice.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class Microservice 14 | { 15 | public function __construct( 16 | #[Assert\NotBlank] private readonly string $name, 17 | #[Assert\Url] private readonly string $baseUri, 18 | private readonly string $apiPath, 19 | #[FormatEnabled] #[Assert\NotBlank] private readonly string $format, 20 | ) { 21 | } 22 | 23 | public function getName(): string 24 | { 25 | return $this->name; 26 | } 27 | 28 | public function getBaseUri(): string 29 | { 30 | return $this->baseUri; 31 | } 32 | 33 | public function getApiPath(): string 34 | { 35 | return $this->apiPath; 36 | } 37 | 38 | public function getFormat(): string 39 | { 40 | return $this->format; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Microservice/MicroservicePool.php: -------------------------------------------------------------------------------- 1 | 18 | * 19 | * @author Mathias Arlaud 20 | */ 21 | class MicroservicePool implements \IteratorAggregate 22 | { 23 | /** 24 | * @var array 25 | */ 26 | private array $microservices = []; 27 | 28 | /** 29 | * @param array> $configs 30 | */ 31 | public function __construct(private readonly ValidatorInterface $validator, private readonly array $configs = []) 32 | { 33 | } 34 | 35 | public function has(string $name): bool 36 | { 37 | return array_key_exists($name, $this->configs); 38 | } 39 | 40 | public function get(string $name): Microservice 41 | { 42 | if (!array_key_exists($name, $this->microservices)) { 43 | $this->microservices[$name] = $this->createMicroservice($name); 44 | } 45 | 46 | return $this->microservices[$name]; 47 | } 48 | 49 | #[\Override] 50 | public function getIterator(): \Traversable 51 | { 52 | foreach (array_keys($this->configs) as $name) { 53 | yield $this->get($name); 54 | } 55 | } 56 | 57 | private function createMicroservice(string $name): Microservice 58 | { 59 | if (!$this->has($name)) { 60 | throw new MicroserviceNotConfiguredException($name); 61 | } 62 | 63 | $config = $this->configs[$name]; 64 | 65 | $microservice = new Microservice($name, $config['base_uri'], $config['api_path'] ?? '', $config['format']); 66 | $this->validateMicroservice($microservice); 67 | 68 | return $microservice; 69 | } 70 | 71 | /** 72 | * @throws MicroserviceConfigurationException 73 | */ 74 | private function validateMicroservice(Microservice $microservice): void 75 | { 76 | $violations = $this->validator->validate($microservice); 77 | 78 | if ($violations->has(0)) { 79 | throw new MicroserviceConfigurationException($microservice->getName(), sprintf("'%s': %s", $violations->get(0)->getPropertyPath(), (string) $violations->get(0)->getMessage())); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Resources/config/hal.php: -------------------------------------------------------------------------------- 1 | services() 16 | ->defaults() 17 | ->private() 18 | ->set('api_platform_ms.hal.encoder', JsonEncoder::class) 19 | ->args([ 20 | 'jsonhal', 21 | ]) 22 | ->tag('serializer.encoder') 23 | ->set('api_platform_ms.hal.denormalizer.constraint_violation_list', ConstraintViolationListDenormalizer::class) 24 | ->call('setNameConverter', [ 25 | service('api_platform.name_converter')->ignoreOnInvalid(), 26 | ]) 27 | ->tag('serializer.normalizer', [ 28 | 'priority' => -880, // Run before api_platform.hal.normalizer.item 29 | ]) 30 | ->set('api_platform_ms.hal.denormalizer.collection', CollectionDenormalizer::class) 31 | ->tag('serializer.normalizer', [ 32 | 'priority' => -870, // Run before api_platform_ms.hal.denormalizer.object 33 | ]) 34 | ->set('api_platform_ms.hal.denormalizer.api_resource', ApiResourceDenormalizer::class) 35 | ->tag('serializer.normalizer', [ 36 | 'priority' => -870, // Run before api_platform_ms.hal.denormalizer.object 37 | ]) 38 | ->set('api_platform_ms.hal.denormalizer.object', ObjectDenormalizer::class) 39 | ->tag('serializer.normalizer', [ 40 | 'priority' => -880, // Run before api_platform.hal.normalizer.item 41 | ]) 42 | ; 43 | }; 44 | -------------------------------------------------------------------------------- /src/Resources/config/hydra.php: -------------------------------------------------------------------------------- 1 | services() 15 | ->defaults() 16 | ->private() 17 | ->set('api_platform_ms.hydra.encoder', JsonEncoder::class) 18 | ->args([ 19 | 'jsonld', 20 | ]) 21 | ->tag('serializer.encoder') 22 | ->set('api_platform_ms.hydra.denormalizer.constraint_violation_list', ConstraintViolationListDenormalizer::class) 23 | ->call('setNameConverter', [ 24 | service('api_platform.name_converter')->ignoreOnInvalid(), 25 | ]) 26 | ->tag('serializer.normalizer', [ 27 | 'priority' => -985, // Run before api_platform.jsonld.normalizer.object 28 | ]) 29 | ->set('api_platform_ms.hydra.denormalizer.collection', CollectionDenormalizer::class) 30 | ->tag('serializer.normalizer', [ 31 | 'priority' => -985, // Run before api_platform.jsonld.normalizer.object 32 | ]) 33 | ->set('api_platform_ms.hydra.denormalizer.api_resource', ApiResourceDenormalizer::class) 34 | ->tag('serializer.normalizer', [ 35 | 'priority' => -985, // Run before api_platform.jsonld.normalizer.object 36 | ]) 37 | ; 38 | }; 39 | -------------------------------------------------------------------------------- /src/Resources/config/jsonapi.php: -------------------------------------------------------------------------------- 1 | services() 16 | ->defaults() 17 | ->private() 18 | ->set('api_platform_ms.jsonapi.encoder', JsonEncoder::class) 19 | ->args([ 20 | 'jsonhal', 21 | ]) 22 | ->tag('serializer.encoder') 23 | ->set('api_platform_ms.jsonapi.denormalizer.constraint_violation_list', ConstraintViolationListDenormalizer::class) 24 | ->call('setNameConverter', [ 25 | service('api_platform.name_converter')->ignoreOnInvalid(), 26 | ]) 27 | ->tag('serializer.normalizer', [ 28 | 'priority' => -880, // Run before api_platform.jsonapi.normalizer.item 29 | ]) 30 | ->set('api_platform_ms.jsonapi.denormalizer.collection', CollectionDenormalizer::class) 31 | ->tag('serializer.normalizer', [ 32 | 'priority' => -870, // Run before api_platform_ms.jsonapi.denormalizer.object 33 | ]) 34 | ->set('api_platform_ms.jsonapi.denormalizer.api_resource', ApiResourceDenormalizer::class) 35 | ->tag('serializer.normalizer', [ 36 | 'priority' => -870, // Run before api_platform_ms.jsonapi.denormalizer.object 37 | ]) 38 | ->set('api_platform_ms.jsonapi.denormalizer.object', ObjectDenormalizer::class) 39 | ->tag('serializer.normalizer', [ 40 | 'priority' => -880, // Run before api_platform.jsonapi.normalizer.item 41 | ]) 42 | ; 43 | }; 44 | -------------------------------------------------------------------------------- /src/Resources/config/routes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | -------------------------------------------------------------------------------- /src/Resources/config/services.php: -------------------------------------------------------------------------------- 1 | services() 24 | ->defaults() 25 | ->private() 26 | ->set('api_platform_ms.route_loader', RouteLoader::class) 27 | ->args([ 28 | param('api_platform_ms.name'), 29 | param('api_platform_ms.hosts'), 30 | ]) 31 | ->tag('routing.route_loader') 32 | ->set('api_platform_ms.microservice_pool', MicroservicePool::class) 33 | ->args([ 34 | service('validator'), 35 | param('api_platform_ms.microservices'), 36 | ]) 37 | ->alias(MicroservicePool::class, 'api_platform_ms.microservice_pool') 38 | ->set(ApiResourceExistenceCheckerAction::class, ApiResourceExistenceCheckerAction::class) 39 | ->args([ 40 | service('serializer'), 41 | service('api_platform.iri_converter'), 42 | service('api_platform.validator'), 43 | ]) 44 | ->tag('controller.service_arguments') 45 | ->set('api_platform_ms.http_client.generic', GenericHttpClient::class) 46 | ->args([ 47 | service('serializer'), 48 | service('api_platform_ms.http_client'), 49 | tagged_iterator('api_platform_ms.authentication_header_provider'), 50 | service('event_dispatcher'), 51 | ]) 52 | ->alias(GenericHttpClient::class, 'api_platform_ms.http_client.generic') 53 | ->set('api_platform_ms.api_resource.existence_checker', ExistenceChecker::class) 54 | ->args([ 55 | service('api_platform_ms.http_client.generic'), 56 | service('serializer'), 57 | service('api_platform_ms.microservice_pool'), 58 | ]) 59 | ->set('api_platform_ms.api_resource.existence_verifier', ExistenceVerifier::class) 60 | ->args([ 61 | service('api_platform_ms.http_client.generic'), 62 | service('api_platform_ms.microservice_pool'), 63 | ]) 64 | ->set('api_platform_ms.validator.api_resource_exist', ApiResourceExistValidator::class) 65 | ->args([ 66 | service('api_platform_ms.api_resource.existence_checker'), 67 | ]) 68 | ->call('setLogger', [ 69 | service('logger'), 70 | ]) 71 | ->tag('validator.constraint_validator') 72 | ->set('api_platform_ms.validator.api_resource_exists', ApiResourceExistsValidator::class) 73 | ->args([ 74 | service('api_platform_ms.api_resource.existence_verifier'), 75 | ]) 76 | ->call('setLogger', [ 77 | service('logger'), 78 | ]) 79 | ->tag('validator.constraint_validator') 80 | ->alias(ApiResourceExistValidator::class, 'api_platform_ms.validator.api_resource_exist') 81 | ->set('api_platform_ms.validator.format_enabled', FormatEnabledValidator::class) 82 | ->args([ 83 | param('api_platform_ms.enabled_formats'), 84 | ]) 85 | ->tag('validator.constraint_validator') 86 | ->set('api_platform_ms.collection.paginated_collection_iterator', PaginatedCollectionIterator::class) 87 | ->args([ 88 | service('api_platform_ms.http_client.generic'), 89 | service('serializer'), 90 | ]) 91 | ->alias(PaginatedCollectionIterator::class, 'api_platform_ms.collection.paginated_collection_iterator') 92 | ->set('api_platform_ms.request_logger_listener', RequestLoggerListener::class) 93 | ->args([ 94 | service('logger'), 95 | ]) 96 | ->tag('kernel.event_listener') 97 | ; 98 | }; 99 | -------------------------------------------------------------------------------- /src/Routing/RouteLoader.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class RouteLoader implements RouteLoaderInterface 21 | { 22 | /** 23 | * @param list $hosts 24 | */ 25 | public function __construct(private readonly string $name, private readonly array $hosts = []) 26 | { 27 | } 28 | 29 | public function __invoke(): RouteCollection 30 | { 31 | $routes = new RouteCollection(); 32 | $iriExistRoute = new Route( 33 | sprintf('/%s_check_resource', $this->name), 34 | ['_controller' => ApiResourceExistenceCheckerAction::class], 35 | [], 36 | [], 37 | '', 38 | [], 39 | ['POST'] 40 | ); 41 | 42 | if (0 < count($this->hosts)) { 43 | $iriExistRoute 44 | ->setRequirements([ 45 | 'host' => implode('|', $this->hosts), 46 | ]) 47 | ->setHost('{host}') 48 | ; 49 | } 50 | 51 | $routes->add(sprintf('%s_check_resource', $this->name), $iriExistRoute); 52 | 53 | return $routes; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Serializer/AbstractApiResourceDenormalizer.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | abstract class AbstractApiResourceDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface 22 | { 23 | use DenormalizerAwareTrait; 24 | 25 | abstract protected function getFormat(): string; 26 | 27 | abstract protected function getIri(array $data): string; 28 | 29 | abstract protected function prepareData(array $data): array; 30 | 31 | abstract protected function prepareEmbeddedData(array $data): array; 32 | 33 | /** 34 | * @throws ExceptionInterface 35 | */ 36 | #[\Override] 37 | public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed 38 | { 39 | $iri = $this->getIri($data); 40 | $data = $this->prepareEmbeddedData($data); 41 | $data = $this->prepareData($data); 42 | $data['iri'] = $iri; 43 | 44 | return $this->denormalizer->denormalize($data, $type, $this->getFormat()); 45 | } 46 | 47 | #[\Override] 48 | public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool 49 | { 50 | return 51 | !isset($data['iri']) 52 | && $this->getFormat() === $format 53 | && is_a($type, ApiResourceDtoInterface::class, true); 54 | } 55 | 56 | public function getSupportedTypes(?string $format): array 57 | { 58 | return ['*' => false]; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Serializer/AbstractCollectionDenormalizer.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | abstract class AbstractCollectionDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface 22 | { 23 | use DenormalizerAwareTrait; 24 | 25 | abstract protected function getFormat(): string; 26 | 27 | /** 28 | * @return array 29 | */ 30 | abstract protected function denormalizeElements(array $data, string $enclosedType, array $context): array; 31 | 32 | abstract protected function isRawCollection(array $data): bool; 33 | 34 | abstract protected function getTotalItems(array $data): int; 35 | 36 | abstract protected function getPagination(array $data): ?Pagination; 37 | 38 | /** 39 | * @return Collection 40 | */ 41 | #[\Override] 42 | public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): Collection 43 | { 44 | if ($this->isRawCollection($data)) { 45 | return new Collection($elements = $this->denormalizeRawElements($data, $this->getEnclosedType($type), $context), count($elements)); 46 | } 47 | 48 | return new Collection( 49 | $this->denormalizeElements($data, $this->getEnclosedType($type), $context), 50 | $this->getTotalItems($data), 51 | $this->getPagination($data) 52 | ); 53 | } 54 | 55 | #[\Override] 56 | public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool 57 | { 58 | if ($this->getFormat() !== $format) { 59 | return false; 60 | } 61 | 62 | $regex = sprintf('#^%s<%s>$#', preg_quote(Collection::class, '#'), '(?P[a-zA-Z0-9\\\\]+)'); 63 | $matches = []; 64 | preg_match($regex, $type, $matches); 65 | 66 | if (empty($enclosedType = $matches['enclosedType'] ?? null)) { 67 | return false; 68 | } 69 | 70 | return $this->denormalizer->supportsDenormalization($data, $enclosedType, $format, $context); 71 | } 72 | 73 | public function getSupportedTypes(?string $format): array 74 | { 75 | return ['*' => true]; 76 | } 77 | 78 | /** 79 | * @return list 80 | */ 81 | protected function denormalizeRawElements(array $data, string $enclosedType, array $context): array 82 | { 83 | return array_map(function (array $elementData) use ($enclosedType, $context) { 84 | /** @var object $rawElement */ 85 | $rawElement = $this->denormalizer->denormalize($elementData, $enclosedType, $this->getFormat(), $context); 86 | 87 | return $rawElement; 88 | }, $data); 89 | } 90 | 91 | protected function getEnclosedType(string $type): string 92 | { 93 | $matches = []; 94 | preg_match('#^[a-zA-Z\\\\]+<(?P[a-zA-Z0-9\\\\]+)>$#', $type, $matches); 95 | 96 | return $matches['enclosedType']; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Serializer/AbstractConstraintViolationListDenormalizer.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | abstract class AbstractConstraintViolationListDenormalizer implements DenormalizerInterface 20 | { 21 | use NameConverterAwareTrait; 22 | 23 | abstract protected function getFormat(): string; 24 | 25 | abstract protected function getViolationsKey(): string; 26 | 27 | abstract protected function denormalizeViolation(array $data): ConstraintViolation; 28 | 29 | /** 30 | * @param class-string $type 31 | * 32 | * @psalm-suppress MoreSpecificImplementedParamType 33 | */ 34 | #[\Override] 35 | public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): ConstraintViolationList 36 | { 37 | return new ConstraintViolationList(array_map(fn (array $violation): ConstraintViolation => $this->denormalizeViolation($violation), $data[$this->getViolationsKey()])); 38 | } 39 | 40 | #[\Override] 41 | public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool 42 | { 43 | return ConstraintViolationList::class === $type && $this->getFormat() === $format; 44 | } 45 | 46 | public function getSupportedTypes(?string $format): array 47 | { 48 | return ['*' => true]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Serializer/Hal/ApiResourceDenormalizer.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class ApiResourceDenormalizer extends AbstractApiResourceDenormalizer 13 | { 14 | use HalDenormalizerTrait; 15 | 16 | #[\Override] 17 | protected function getIri(array $data): string 18 | { 19 | return $data['_links']['self']['href']; 20 | } 21 | 22 | #[\Override] 23 | protected function prepareData(array $data): array 24 | { 25 | return $data; 26 | } 27 | 28 | #[\Override] 29 | protected function prepareEmbeddedData(array $data): array 30 | { 31 | if (!isset($data['_links'], $data['_embedded'])) { 32 | return $data; 33 | } 34 | 35 | return $this->fillRelationships($data, $this->indexEmbeddedElements($data['_embedded'])); 36 | } 37 | 38 | private function indexEmbeddedElements(array $embeddedElements): array 39 | { 40 | $indexedEmbeddedElements = []; 41 | 42 | foreach ($embeddedElements as $embeddedElement) { 43 | // Collection 44 | if (!isset($embeddedElement['_links']['self']['href'])) { 45 | foreach ($embeddedElement as $embeddedElementItem) { 46 | $itemLink = $embeddedElementItem['_links']['self']['href']; 47 | $indexedEmbeddedElements[$itemLink] = $embeddedElementItem + ['iri' => $itemLink]; 48 | $indexedEmbeddedElements += $this->indexEmbeddedElements($embeddedElementItem['_embedded']); 49 | } 50 | 51 | continue; 52 | } 53 | 54 | // Single item 55 | if (!isset($indexedEmbeddedElements[$link = $embeddedElement['_links']['self']['href']])) { 56 | $indexedEmbeddedElements[$link] = $embeddedElement + ['iri' => $link]; 57 | } 58 | } 59 | 60 | return $indexedEmbeddedElements; 61 | } 62 | 63 | private function fillRelationships(array $data, array $indexedEmbeddedElements): array 64 | { 65 | foreach ($data['_links'] as $linkName => $linkValue) { 66 | if ('self' === $linkName) { 67 | continue; 68 | } 69 | 70 | // Collection 71 | if (!isset($linkValue['href'])) { 72 | $data[$linkName] = array_map(fn (array $item): array => $this->fillRelationships($indexedEmbeddedElements[$item['href']], $indexedEmbeddedElements), $linkValue); 73 | 74 | continue; 75 | } 76 | 77 | // Single item 78 | $data[$linkName] = $indexedEmbeddedElements[$linkValue['href']]; 79 | } 80 | 81 | return $data; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Serializer/Hal/CollectionDenormalizer.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class CollectionDenormalizer extends AbstractCollectionDenormalizer 17 | { 18 | use HalDenormalizerTrait; 19 | 20 | /** 21 | * @return array 22 | */ 23 | #[\Override] 24 | protected function denormalizeElements(array $data, string $enclosedType, array $context): array 25 | { 26 | return array_map(function (array $elementData) use ($enclosedType, $context) { 27 | /** @var object $element */ 28 | $element = $this->denormalizer->denormalize($elementData, $enclosedType, $this->getFormat(), $context); 29 | 30 | return $element; 31 | }, $data['_embedded']['item']); 32 | } 33 | 34 | #[\Override] 35 | protected function getTotalItems(array $data): int 36 | { 37 | return $data['totalItems']; 38 | } 39 | 40 | #[\Override] 41 | protected function getPagination(array $data): ?Pagination 42 | { 43 | return array_key_exists('first', $links = $data['_links']) 44 | ? new Pagination( 45 | $links['self']['href'], 46 | $links['first']['href'], 47 | $links['last']['href'], 48 | $links['prev']['href'] ?? null, 49 | $links['next']['href'] ?? null 50 | ) 51 | : null; 52 | } 53 | 54 | #[\Override] 55 | protected function isRawCollection(array $data): bool 56 | { 57 | return !array_key_exists('_embedded', $data); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Serializer/Hal/ConstraintViolationListDenormalizer.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class ConstraintViolationListDenormalizer extends AbstractConstraintViolationListDenormalizer 17 | { 18 | use HalDenormalizerTrait; 19 | 20 | #[\Override] 21 | protected function getViolationsKey(): string 22 | { 23 | return 'violations'; 24 | } 25 | 26 | #[\Override] 27 | protected function denormalizeViolation(array $data): ConstraintViolation 28 | { 29 | return new ConstraintViolation( 30 | $data['title'], 31 | null, 32 | [], 33 | null, 34 | $this->nameConverter ? $this->nameConverter->denormalize($data['propertyPath']) : $data['propertyPath'], 35 | null 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Serializer/Hal/HalDenormalizerTrait.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | trait HalDenormalizerTrait 11 | { 12 | protected function getFormat(): string 13 | { 14 | return 'jsonhal'; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Serializer/Hal/ObjectDenormalizer.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class ObjectDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface 17 | { 18 | use HalDenormalizerTrait; 19 | use DenormalizerAwareTrait; 20 | 21 | #[\Override] 22 | public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed 23 | { 24 | return $this->denormalizer->denormalize($data, $type, 'json', $context); 25 | } 26 | 27 | #[\Override] 28 | public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool 29 | { 30 | return $this->getFormat() === $format; 31 | } 32 | 33 | public function getSupportedTypes(?string $format): array 34 | { 35 | return ['*' => true]; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Serializer/Hydra/ApiResourceDenormalizer.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class ApiResourceDenormalizer extends AbstractApiResourceDenormalizer 13 | { 14 | use HydraDenormalizerTrait; 15 | 16 | #[\Override] 17 | protected function getIri(array $data): string 18 | { 19 | return $data['@id']; 20 | } 21 | 22 | #[\Override] 23 | protected function prepareData(array $data): array 24 | { 25 | return $data; 26 | } 27 | 28 | #[\Override] 29 | protected function prepareEmbeddedData(array $data): array 30 | { 31 | return $data; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Serializer/Hydra/CollectionDenormalizer.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class CollectionDenormalizer extends AbstractCollectionDenormalizer 17 | { 18 | use HydraDenormalizerTrait; 19 | 20 | #[\Override] 21 | protected function denormalizeElements(array $data, string $enclosedType, array $context): array 22 | { 23 | return array_map(function (array $elementData) use ($enclosedType, $context) { 24 | /** @var object $element */ 25 | $element = $this->denormalizer->denormalize($elementData, $enclosedType, $this->getFormat(), $context); 26 | 27 | return $element; 28 | }, $data['hydra:member'] ?? $data['member']); 29 | } 30 | 31 | #[\Override] 32 | protected function getTotalItems(array $data): int 33 | { 34 | return $data['hydra:totalItems'] ?? $data['totalItems']; 35 | } 36 | 37 | #[\Override] 38 | protected function getPagination(array $data): ?Pagination 39 | { 40 | $view = $data['hydra:view'] ?? $data['view'] ?? []; 41 | 42 | return array_key_exists('hydra:first', $view) || array_key_exists('first', $view) 43 | ? new Pagination( 44 | $view['@id'], 45 | $view['hydra:first'] ?? $view['first'], 46 | $view['hydra:last'] ?? $view['last'], 47 | $view['hydra:previous'] ?? $view['previous'] ?? null, 48 | $view['hydra:next'] ?? $view['next'] ?? null 49 | ) 50 | : null; 51 | } 52 | 53 | #[\Override] 54 | protected function isRawCollection(array $data): bool 55 | { 56 | return !array_key_exists('@type', $data); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Serializer/Hydra/ConstraintViolationListDenormalizer.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class ConstraintViolationListDenormalizer extends AbstractConstraintViolationListDenormalizer 17 | { 18 | use HydraDenormalizerTrait; 19 | 20 | #[\Override] 21 | protected function getViolationsKey(): string 22 | { 23 | return 'violations'; 24 | } 25 | 26 | #[\Override] 27 | protected function denormalizeViolation(array $data): ConstraintViolation 28 | { 29 | return new ConstraintViolation( 30 | $data['message'], 31 | null, 32 | [], 33 | null, 34 | $this->nameConverter ? $this->nameConverter->denormalize($data['propertyPath']) : $data['propertyPath'], 35 | null 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Serializer/Hydra/HydraDenormalizerTrait.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | trait HydraDenormalizerTrait 11 | { 12 | protected function getFormat(): string 13 | { 14 | return 'jsonld'; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Serializer/JsonApi/ApiResourceDenormalizer.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class ApiResourceDenormalizer extends AbstractApiResourceDenormalizer 13 | { 14 | use JsonApiDenormalizerTrait; 15 | 16 | #[\Override] 17 | protected function getIri(array $data): string 18 | { 19 | return $data['data']['id']; 20 | } 21 | 22 | #[\Override] 23 | protected function prepareData(array $data): array 24 | { 25 | return $data['data']['attributes']; 26 | } 27 | 28 | /** 29 | * @param array{data: array, included: array>} $data 30 | * 31 | * @psalm-suppress MoreSpecificImplementedParamType 32 | */ 33 | #[\Override] 34 | protected function prepareEmbeddedData(array $data): array 35 | { 36 | if (!isset($data['data']['relationships'], $data['included'])) { 37 | return $data; 38 | } 39 | 40 | $data['data']['attributes'] += $this->fillRelationships($data['data'], $this->indexEmbeddedElements($data)); 41 | 42 | return $data; 43 | } 44 | 45 | private function indexEmbeddedElements(array $data): array 46 | { 47 | $indexedEmbeddedElements = []; 48 | 49 | foreach ($data['included'] as $embeddedElement) { 50 | $type = $embeddedElement['type']; 51 | $indexedEmbeddedElements[$type] ??= []; 52 | $indexedEmbeddedElements[$type][$embeddedElement['id']] = $embeddedElement; 53 | } 54 | 55 | return $indexedEmbeddedElements; 56 | } 57 | 58 | private function fillRelationships(array $relationships, array $indexedEmbeddedElements): array 59 | { 60 | $result = []; 61 | 62 | foreach ($relationships['relationships'] ?? [] as $relationshipName => $relationship) { 63 | $relationshipData = $relationship['data']; 64 | 65 | // Collection 66 | if (!isset($relationshipData['type'])) { 67 | foreach ($relationshipData as $key => $relationshipItem) { 68 | $result[$relationshipName] ??= []; 69 | 70 | if (null !== $includedElement = $indexedEmbeddedElements[$relationshipItem['type']][$relationshipItem['id']] ?? null) { 71 | $result[$relationshipName][$key]['data'] = $includedElement; 72 | $result[$relationshipName][$key]['data']['attributes'] += $this->fillRelationships($includedElement, $indexedEmbeddedElements); 73 | } 74 | } 75 | 76 | continue; 77 | } 78 | 79 | // Single item 80 | $result[$relationshipName]['data'] = $indexedEmbeddedElements[$relationshipData['type']][$relationshipData['id']]; 81 | } 82 | 83 | return $result; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Serializer/JsonApi/CollectionDenormalizer.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class CollectionDenormalizer extends AbstractCollectionDenormalizer 17 | { 18 | use JsonApiDenormalizerTrait; 19 | 20 | /** 21 | * @return array 22 | */ 23 | #[\Override] 24 | protected function denormalizeElements(array $data, string $enclosedType, array $context): array 25 | { 26 | return array_map(function (array $elementData) use ($enclosedType, $context) { 27 | /** @var object $element */ 28 | $element = $this->denormalizer->denormalize(['data' => $elementData], $enclosedType, $this->getFormat(), $context); 29 | 30 | return $element; 31 | }, $data['data']); 32 | } 33 | 34 | #[\Override] 35 | protected function getTotalItems(array $data): int 36 | { 37 | return $data['meta']['totalItems']; 38 | } 39 | 40 | #[\Override] 41 | protected function getPagination(array $data): ?Pagination 42 | { 43 | return !empty($data['links']['first'] ?? null) 44 | ? new Pagination( 45 | $data['links']['self'], 46 | $data['links']['first'], 47 | $data['links']['last'], 48 | $data['links']['prev'] ?? null, 49 | $data['links']['next'] ?? null 50 | ) 51 | : null; 52 | } 53 | 54 | #[\Override] 55 | protected function isRawCollection(array $data): bool 56 | { 57 | return !array_key_exists('data', $data); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Serializer/JsonApi/ConstraintViolationListDenormalizer.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class ConstraintViolationListDenormalizer extends AbstractConstraintViolationListDenormalizer 17 | { 18 | use JsonApiDenormalizerTrait; 19 | 20 | #[\Override] 21 | protected function getViolationsKey(): string 22 | { 23 | return 'errors'; 24 | } 25 | 26 | #[\Override] 27 | protected function denormalizeViolation(array $data): ConstraintViolation 28 | { 29 | $pointerParts = explode('/', (string) $data['source']['pointer']); 30 | 31 | return new ConstraintViolation( 32 | $data['detail'], 33 | null, 34 | [], 35 | null, 36 | $this->nameConverter ? $this->nameConverter->denormalize(end($pointerParts)) : end($pointerParts), 37 | null 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Serializer/JsonApi/JsonApiDenormalizerTrait.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | trait JsonApiDenormalizerTrait 11 | { 12 | protected function getFormat(): string 13 | { 14 | return 'jsonapi'; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Serializer/JsonApi/ObjectDenormalizer.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class ObjectDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface 21 | { 22 | use JsonApiDenormalizerTrait; 23 | use DenormalizerAwareTrait; 24 | 25 | private const ALREADY_CALLED = 'jsonapi_object_denormalizer_already_called'; 26 | 27 | #[\Override] 28 | public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed 29 | { 30 | $context[self::ALREADY_CALLED] = true; 31 | 32 | if (isset($data['data'])) { 33 | $data = $data['data']; 34 | } 35 | 36 | if (isset($data['attributes'])) { 37 | $data = $data['attributes']; 38 | } 39 | 40 | $data = $this->convertReservedAttributeNames($data); 41 | 42 | return $this->denormalizer->denormalize($data, $type, $format, $context); 43 | } 44 | 45 | #[\Override] 46 | public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool 47 | { 48 | return false === ($context[self::ALREADY_CALLED] ?? false) && $this->getFormat() === $format; 49 | } 50 | 51 | public function getSupportedTypes(?string $format): array 52 | { 53 | return ['*' => false]; 54 | } 55 | 56 | private function convertReservedAttributeNames(array $data): array 57 | { 58 | $reservedAttributes = array_flip(ReservedAttributeNameConverter::JSON_API_RESERVED_ATTRIBUTES); 59 | 60 | foreach ($data as $key => $value) { 61 | // Collection 62 | if (is_array($value)) { 63 | $data[$key] = $this->convertReservedAttributeNames($data[$key]); 64 | } 65 | 66 | // Item 67 | if (isset($reservedAttributes[$key])) { 68 | $data[$reservedAttributes[$key]] = $value; 69 | unset($data[$key]); 70 | } 71 | } 72 | 73 | return $data; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Serializer/NameConverterAwareTrait.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | trait NameConverterAwareTrait 13 | { 14 | /** 15 | * @var NameConverterInterface|null 16 | */ 17 | protected $nameConverter; 18 | 19 | public function setNameConverter(?NameConverterInterface $nameConverter): void 20 | { 21 | $this->nameConverter = $nameConverter; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Validator/ApiResourceExist.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] 13 | final class ApiResourceExist extends Constraint 14 | { 15 | /** 16 | * @var string 17 | * 18 | * Violation message 19 | */ 20 | public $message = "'{{ iri }}' does not exist in microservice '{{ microservice }}'."; 21 | 22 | /** 23 | * @var string 24 | * 25 | * Microservice name 26 | */ 27 | public $microservice; 28 | 29 | /** 30 | * @var bool 31 | * 32 | * Skip validation on http errors 33 | */ 34 | public $skipOnError = false; 35 | 36 | /** 37 | * @param string|array $microservice The target microservice or a set of options 38 | * @param array $groups 39 | * @param array $options 40 | * 41 | * @psalm-suppress DocblockTypeContradiction 42 | */ 43 | public function __construct( 44 | $microservice = null, 45 | ?bool $skipOnError = null, 46 | ?string $message = null, 47 | ?array $groups = null, 48 | mixed $payload = null, 49 | array $options = [], 50 | ) { 51 | is_array($microservice) ? $options = array_merge($microservice, $options) : $options['value'] = $microservice; 52 | 53 | parent::__construct($options, $groups, $payload); 54 | 55 | if (!is_string($this->microservice)) { 56 | throw new \TypeError(sprintf('"%s()": Expected argument $microservice to be a string, got "%s".', __METHOD__, get_debug_type($this->microservice))); 57 | } 58 | 59 | $this->message = $message ?? $this->message; 60 | $this->skipOnError = $skipOnError ?? $this->skipOnError; 61 | 62 | trigger_deprecation('mtarld/api-platform-ms-bundle', '1.3.0', sprintf('%s is deprecated, use %s instead.', self::class, ApiResourceExists::class)); 63 | } 64 | 65 | #[\Override] 66 | public function getDefaultOption(): string 67 | { 68 | return 'microservice'; 69 | } 70 | 71 | #[\Override] 72 | public function getRequiredOptions(): array 73 | { 74 | return ['microservice']; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Validator/ApiResourceExistValidator.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | class ApiResourceExistValidator extends ConstraintValidator 25 | { 26 | use LoggerAwareTrait; 27 | 28 | public function __construct(private readonly ExistenceChecker $existenceChecker) 29 | { 30 | } 31 | 32 | /** 33 | * @psalm-param string|list|null $value 34 | * 35 | * @psalm-suppress MoreSpecificImplementedParamType 36 | */ 37 | #[\Override] 38 | public function validate($value, Constraint $constraint): void 39 | { 40 | if (!$constraint instanceof ApiResourceExist) { 41 | throw new UnexpectedTypeException($constraint, ApiResourceExist::class); 42 | } 43 | 44 | if (null === $value) { 45 | return; 46 | } 47 | 48 | $this->validateIris((array) $value, $constraint); 49 | } 50 | 51 | /** 52 | * @param list $iris 53 | */ 54 | private function validateIris(array $iris, ApiResourceExist $constraint): void 55 | { 56 | try { 57 | $checkedIris = $this->existenceChecker->getExistenceStatuses($constraint->microservice, $iris); 58 | 59 | foreach ($checkedIris as $iri => $valid) { 60 | if (false === $valid) { 61 | $this->context->buildViolation($constraint->message) 62 | ->setParameter('{{ iri }}', $iri) 63 | ->setParameter('{{ microservice }}', $constraint->microservice) 64 | ->addViolation() 65 | ; 66 | } 67 | } 68 | } catch (HttpClientExceptionInterface|SerializerExceptionInterface $e) { 69 | $this->handleExistenceCheckerHttpException($e, $constraint); 70 | } 71 | } 72 | 73 | private function handleExistenceCheckerHttpException(\Throwable $exception, ApiResourceExist $constraint): void 74 | { 75 | $message = sprintf( 76 | "Unable to validate IRIs of microservice '%s': %s", 77 | $constraint->microservice, 78 | $exception->getMessage() 79 | ); 80 | 81 | if (null !== $this->logger) { 82 | $this->logger->debug($message); 83 | } 84 | 85 | if ($constraint->skipOnError) { 86 | return; 87 | } 88 | 89 | throw new \RuntimeException($message); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Validator/ApiResourceExists.php: -------------------------------------------------------------------------------- 1 | 'IRI_NOT_FOUND_ERROR', 14 | ]; 15 | 16 | /** 17 | * @param string $microservice The name of the target microservice 18 | * @param bool $skipOnError Skip validation on HTTP errors 19 | * @param string $regexPattern The regex pattern which an IRI must match 20 | * 21 | * @SuppressWarnings(PHPMD.BooleanArgumentFlag) 22 | */ 23 | public function __construct( 24 | public string $microservice, 25 | public bool $skipOnError = false, 26 | public string $regexPattern = '/^\/[\w-]+(\/[\w-]+)+$/', 27 | public string $message = "'{{ iri }}' does not exist in microservice '{{ microservice }}'", 28 | ?array $groups = null, 29 | mixed $payload = null, 30 | ) { 31 | parent::__construct([], $groups, $payload); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Validator/ApiResourceExistsValidator.php: -------------------------------------------------------------------------------- 1 | context->getViolations()); 37 | 38 | $this->checkPreconstraints($value, $constraint); 39 | 40 | if ($currentViolations === count($this->context->getViolations())) { 41 | try { 42 | if (!$this->existenceVerifier->verify($constraint->microservice, $value)) { 43 | $this->context->buildViolation($constraint->message) 44 | ->setParameter('{{ iri }}', $value) 45 | ->setParameter('{{ microservice }}', $constraint->microservice) 46 | ->setCode(ApiResourceExists::IRI_NOT_FOUND_ERROR) 47 | ->addViolation() 48 | ; 49 | } 50 | } catch (ExceptionInterface $e) { 51 | $this->handleHttpException($e, $constraint); 52 | } 53 | } 54 | } 55 | 56 | private function checkPreconstraints(mixed $value, ApiResourceExists $constraint): void 57 | { 58 | $constraints = [ 59 | new Assert\Regex(pattern: $constraint->regexPattern), 60 | ]; 61 | $validator = $this->context->getValidator()->inContext($this->context); 62 | 63 | $validator->validate($value, $constraints); 64 | } 65 | 66 | private function handleHttpException(ExceptionInterface $exception, ApiResourceExists $constraint): void 67 | { 68 | $message = sprintf( 69 | "Unable to validate IRIs of microservice '%s': %s", 70 | $constraint->microservice, 71 | $exception->getMessage() 72 | ); 73 | 74 | $this->logger?->debug($message); 75 | 76 | if ($constraint->skipOnError) { 77 | return; 78 | } 79 | 80 | throw new \RuntimeException($message); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Validator/FormatEnabled.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] 15 | class FormatEnabled extends Constraint 16 | { 17 | /** 18 | * @var string 19 | * 20 | * Violation message 21 | */ 22 | public $message = "'{{ format }}' format is not enabled."; 23 | 24 | /** 25 | * @param array $options 26 | * @param array $groups 27 | */ 28 | public function __construct(array $options = [], ?string $message = null, ?array $groups = null, mixed $payload = null) 29 | { 30 | parent::__construct($options, $groups, $payload); 31 | 32 | $this->message = $message ?? $this->message; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Validator/FormatEnabledValidator.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class FormatEnabledValidator extends ConstraintValidator 16 | { 17 | public function __construct(private readonly array $enabledFormats) 18 | { 19 | } 20 | 21 | /** 22 | * @param FormatEnabled $constraint 23 | * @param string $value 24 | * 25 | * @psalm-suppress MoreSpecificImplementedParamType 26 | */ 27 | #[\Override] 28 | public function validate($value, Constraint $constraint): void 29 | { 30 | if (!in_array($value, $this->enabledFormats, true)) { 31 | $this->context->buildViolation($constraint->message) 32 | ->setParameter('{{ format }}', $value) 33 | ->addViolation() 34 | ; 35 | } 36 | } 37 | } 38 | --------------------------------------------------------------------------------