From e9d38d2cda4c6907449970137e87449b3284473f Mon Sep 17 00:00:00 2001 From: Nicolas Joubert Date: Thu, 15 Oct 2026 17:54:04 +0200 Subject: [PATCH 1/2] #52 - Load the SOAP clients lazily from a service locator indexed by code - RegisterClientsPass: the services tagged cleverage.soap.client with a code attribute are registered in a service locator passed to the ClientRegistry (new optional constructor argument): a client is only instantiated when a task or the transformer uses it. Duplicate or empty codes fail the container compilation. Without code, the client is still registered with addClient(), now deprecated (the code will be required in 4.0). - ClientRegistry: getClient() checks that the service implements ClientInterface and that its getCode() is the code of the tag; getClients() instantiates every client; FQCN alias to autowire it. - New #[AsSoapClient(code: '...')] attribute (autoconfiguration), adding the tag with its code. - Require symfony/service-contracts (ServiceProviderInterface). - Docs: code attribute in every example, registration notes. Same model as process-bundle#276 (TransformerRegistry) and rest#58. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + composer.json | 3 +- config/services/registry.yaml | 6 + docs/index.md | 7 +- docs/reference/client.md | 25 ++-- src/Attribute/AsSoapClient.php | 29 +++++ .../CleverAgeSoapProcessExtension.php | 9 ++ .../Compiler/RegisterClientsPass.php | 36 +++++- src/Registry/ClientRegistry.php | 66 ++++++++-- .../CleverAgeSoapProcessExtensionTest.php | 22 ++++ .../Compiler/RegisterClientsPassTest.php | 59 +++++++-- tests/Fixtures/AttributedClient.php | 22 ++++ tests/Registry/ClientRegistryTest.php | 120 ++++++++++++++++-- tests/Task/RequestTaskTest.php | 5 +- tests/Transformer/RequestTransformerTest.php | 5 +- 15 files changed, 362 insertions(+), 53 deletions(-) create mode 100644 src/Attribute/AsSoapClient.php create mode 100644 tests/Fixtures/AttributedClient.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 769718e..1553437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Latest ------ ### Changes +* [#52](https://github.com/cleverage/soap-process-bundle/issues/52) ClientRegistry: the clients are loaded lazily from a service locator indexed by code: a client is only instantiated when a task or the transformer uses it (an error in a client that a process does not use no longer makes it fail). The code is given by the new `code` attribute of the `cleverage.soap.client` tag, or by the new `#[AsSoapClient(code: '...')]` attribute (autoconfiguration); it must be the one returned by `getCode()`. Duplicate or empty codes fail the container compilation. A client tagged without `code` is still registered eagerly, with `ClientRegistry::addClient()`, which is deprecated (the `code` will be required in 4.0). New optional `ClientRegistry` constructor argument (the locator); `ClientRegistry` is autowirable by its class name. Require `symfony/service-contracts`. Update documentation, add tests. * [#50](https://github.com/cleverage/soap-process-bundle/issues/50) Require `symfony/polyfill-php83`, `symfony/polyfill-php84` and `symfony/polyfill-php85` ([cleverage/process-bundle#299](https://github.com/cleverage/process-bundle/issues/299)), so the functions of PHP 8.3 to 8.5 can be used while supporting PHP 8.2. Configure Rector for PHP 8.2 with its polyfills set (it was configured for PHP 8.5, and could introduce PHP 8.3+ functions without polyfill). No code change: no Rector rule applies and no manual replacement keeps an identical behaviour. Document it in `CONTRIBUTING.md`. * [#46](https://github.com/cleverage/soap-process-bundle/issues/46) Give a code to the task ([cleverage/process-bundle#91](https://github.com/cleverage/process-bundle/issues/91)): `soap_request` (`task: soap_request`, with `cleverage/process-bundle` 5.4 or later, referencing it by `service` being deprecated there). The task has its class name as service id; fetching it directly from the service container is deprecated (`container.private`), it will be private in 4.0. The former id `cleverage_soap_process.task.request` is a deprecated alias, removed in 4.0. Update documentation, add tests. * [#45](https://github.com/cleverage/soap-process-bundle/issues/45) Tag the `RequestTransformer` with its code, `soap_request`, for its lazy loading by `cleverage/process-bundle` 5.4 or later ([cleverage/process-bundle#276](https://github.com/cleverage/process-bundle/issues/276)). The transformer has its class name as service id; fetching it directly from the service container is deprecated (`container.private`), it will be private in 4.0. The former id `cleverage_soap_process.transformer.request` is a deprecated alias, removed in 4.0. Update documentation, add tests. diff --git a/composer.json b/composer.json index 21b1fb6..0e7bf36 100644 --- a/composer.json +++ b/composer.json @@ -60,7 +60,8 @@ "symfony/options-resolver": "^6.4|^7.4|^8", "symfony/polyfill-php83": "^1.33", "symfony/polyfill-php84": "^1.33", - "symfony/polyfill-php85": "^1.33" + "symfony/polyfill-php85": "^1.33", + "symfony/service-contracts": "^2.5 || ^3" }, "require-dev": { "friendsofphp/php-cs-fixer": "*", diff --git a/config/services/registry.yaml b/config/services/registry.yaml index 80986ba..9ff45ba 100644 --- a/config/services/registry.yaml +++ b/config/services/registry.yaml @@ -2,3 +2,9 @@ services: cleverage_soap_process.registry.client: class: CleverAge\SoapProcessBundle\Registry\ClientRegistry public: false + arguments: + - ~ # Service locator of the clients by code, see RegisterClientsPass + + CleverAge\SoapProcessBundle\Registry\ClientRegistry: + alias: cleverage_soap_process.registry.client + public: false diff --git a/docs/index.md b/docs/index.md index 410e6c3..a00f556 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,8 +22,9 @@ CleverAge\SoapProcessBundle\CleverAgeSoapProcessBundle::class => ['all' => true] ## Configuration -The bundle has no configuration. Declare at least one SOAP client as a service tagged `cleverage.soap.client`, its -`code` is then used by the `client` option of the task and the transformer (see [Client](reference/client.md)): +The bundle has no configuration. Declare at least one SOAP client as a service tagged `cleverage.soap.client` with its +`code` (or with the `#[AsSoapClient]` attribute), which is then used by the `client` option of the task and the +transformer (see [Client](reference/client.md)): ```yaml # config/services.yaml @@ -38,7 +39,7 @@ services: exceptions: true features: !php/const SOAP_SINGLE_ELEMENT_ARRAYS tags: - - { name: cleverage.soap.client } + - { name: cleverage.soap.client, code: country_info } ``` The task is registered as a service (non-shared) and is used in your processes by its code, `soap_request` (e.g. diff --git a/docs/reference/client.md b/docs/reference/client.md index 14efa54..9f012f3 100644 --- a/docs/reference/client.md +++ b/docs/reference/client.md @@ -10,8 +10,11 @@ Client reference * **Interface**: `CleverAge\SoapProcessBundle\Client\ClientInterface` * **Base class**: `CleverAge\SoapProcessBundle\Client\Client` -* **Service tag**: `cleverage.soap.client`, every tagged service is registered in the - `cleverage_soap_process.registry.client` registry (`CleverAge\SoapProcessBundle\Registry\ClientRegistry`) +* **Service tag**: `cleverage.soap.client`, with a `code` attribute (`- { name: cleverage.soap.client, code: my_api }`): + every tagged service is registered in the `cleverage_soap_process.registry.client` registry + (`CleverAge\SoapProcessBundle\Registry\ClientRegistry`, autowirable by its class name) +* **Attribute**: `CleverAge\SoapProcessBundle\Attribute\AsSoapClient` (`#[AsSoapClient(code: 'my_api')]`, with + autoconfiguration), equivalent to the tag The bundle does not declare any client: you have to register at least one service, either with the `CleverAge\SoapProcessBundle\Client\Client` class or with your own implementation of `ClientInterface`. @@ -66,7 +69,7 @@ services: cache_wsdl: !php/const WSDL_CACHE_BOTH features: !php/const SOAP_SINGLE_ELEMENT_ARRAYS tags: - - { name: cleverage.soap.client } + - { name: cleverage.soap.client, code: country_info } ``` With `autowire: true` in the `_defaults` of your `config/services.yaml`, the `$logger` argument can be omitted. @@ -88,7 +91,7 @@ services: login: '%env(ERP_SOAP_LOGIN)%' password: '%env(ERP_SOAP_PASSWORD)%' tags: - - { name: cleverage.soap.client } + - { name: cleverage.soap.client, code: legacy_erp } ``` * Custom client, overriding the call of one SOAP method @@ -122,10 +125,16 @@ class CountryInfoClient extends Client Notes ----- -* Codes must be unique: registering two clients with the same code throws an `\UnexpectedValueException` giving the - ids of both services (`Client is already defined by service "", cannot register service ""`) when - the registry is instantiated, i.e. when the first SOAP task or - transformer service is built. +* The clients are registered by the `code` attribute of their tag, in a service locator built when the container is + compiled: a client is only instantiated when a task or the transformer uses it (an error in a client that a process + does not use does not make it fail). The `code` must be the one returned by `getCode()` (an + `\UnexpectedValueException` is thrown otherwise when it is fetched). Two clients with the same code, or an empty + code, fail the container compilation. +* **Deprecated**: a service tagged without `code` is still registered, with `ClientRegistry::addClient()`, instantiated + with the registry (deprecated since 3.3, the `code` will be required in 4.0). Two clients with the same code then + throw an `\UnexpectedValueException` giving the ids of both services + (`Client is already defined by service "", cannot register service ""`) when the registry is + instantiated, i.e. when the first SOAP task or transformer service is built. * Using a code that is not registered throws a `CleverAge\SoapProcessBundle\Exception\MissingClientException` (`No Soap client with code : `). * The `SoapClient` is created lazily, on the first call, and then reused: the WSDL is only loaded once per client. diff --git a/src/Attribute/AsSoapClient.php b/src/Attribute/AsSoapClient.php new file mode 100644 index 0000000..fc3d35b --- /dev/null +++ b/src/Attribute/AsSoapClient.php @@ -0,0 +1,29 @@ +findServices($container, __DIR__.'/../../config/services'); + + $container->registerAttributeForAutoconfiguration( + AsSoapClient::class, + static function (ChildDefinition $definition, AsSoapClient $attribute): void { + $definition->addTag('cleverage.soap.client', ['code' => $attribute->code]); + } + ); } /** diff --git a/src/DependencyInjection/Compiler/RegisterClientsPass.php b/src/DependencyInjection/Compiler/RegisterClientsPass.php index 71c1fa1..18ace92 100644 --- a/src/DependencyInjection/Compiler/RegisterClientsPass.php +++ b/src/DependencyInjection/Compiler/RegisterClientsPass.php @@ -14,11 +14,14 @@ namespace CleverAge\SoapProcessBundle\DependencyInjection\Compiler; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Reference; /** - * Adds the tagged SOAP clients to the client registry, with their service id. + * Registers the `cleverage.soap.client` tagged services in the client registry: in a service locator indexed by the + * `code` attribute of the tag (lazy), or, without `code` (deprecated), with an addClient() call (eager). */ class RegisterClientsPass implements CompilerPassInterface { @@ -27,10 +30,35 @@ public function process(ContainerBuilder $container): void if (!$container->has('cleverage_soap_process.registry.client')) { return; } - $definition = $container->findDefinition('cleverage_soap_process.registry.client'); - foreach (array_keys($container->findTaggedServiceIds('cleverage.soap.client')) as $id) { - $definition->addMethodCall('addClient', [new Reference($id), $id]); + + $services = []; + $ids = []; + foreach ($container->findTaggedServiceIds('cleverage.soap.client') as $id => $tags) { + $codes = []; + foreach ($tags as $attributes) { + if (!\is_array($attributes) || !isset($attributes['code'])) { + continue; + } + if (!\is_string($attributes['code']) || '' === $attributes['code']) { + throw new InvalidArgumentException(\sprintf('The "code" attribute of the "cleverage.soap.client" tag of the "%s" service must be a non-empty string.', $id)); + } + $codes[] = $attributes['code']; + } + if ([] === $codes) { + // Deprecated: instantiated with the registry, addClient() triggers the deprecation + $definition->addMethodCall('addClient', [new Reference($id), $id]); + continue; + } + foreach (array_unique($codes) as $code) { + if (isset($ids[$code])) { + throw new InvalidArgumentException(\sprintf('The SOAP client code "%s" is used by the "%s" and "%s" services.', $code, $ids[$code], $id)); + } + $ids[$code] = $id; + $services[$code] = new Reference($id); + } } + + $definition->setArgument(0, ServiceLocatorTagPass::register($container, $services)); } } diff --git a/src/Registry/ClientRegistry.php b/src/Registry/ClientRegistry.php index ec4cceb..e408f0f 100644 --- a/src/Registry/ClientRegistry.php +++ b/src/Registry/ClientRegistry.php @@ -15,30 +15,59 @@ use CleverAge\SoapProcessBundle\Client\ClientInterface; use CleverAge\SoapProcessBundle\Exception\MissingClientException; +use Symfony\Contracts\Service\ServiceProviderInterface; /** - * Holds all tagged soap client services. + * Holds the SOAP clients, indexed by code. + * + * The client services are fetched lazily from a service locator indexed by code (`code` attribute of the + * `cleverage.soap.client` tag, or `#[AsSoapClient]`, see RegisterClientsPass): a client is only instantiated when it + * is used. * * @author Madeline Veyrenc */ class ClientRegistry { - /** @var ClientInterface[] */ + /** + * Clients already fetched from the locator, and the ones registered with addClient() (deprecated). + * + * @var array + */ private array $clients = []; - /** @var array Service ids of the clients, indexed by code */ + /** @var array Service ids of the clients registered with addClient(), indexed by code */ private array $serviceIds = []; + /** + * @param ServiceProviderInterface|null $locator Client services, checked when fetched + */ + public function __construct( + private readonly ?ServiceProviderInterface $locator = null, + ) { + } + /** * @param string|null $serviceId Id of the client service, used to identify the clients with the same code + * + * @deprecated since 3.3, tag the client service with a "code" attribute or use #[AsSoapClient] instead */ public function addClient(ClientInterface $client, ?string $serviceId = null): void { $code = $client->getCode(); - if (\array_key_exists($code, $this->getClients())) { + trigger_deprecation( + 'cleverage/soap-process-bundle', + '3.3', + 'Registering the SOAP client "%s" (%s) with "%s()" is deprecated, tag its service with a "code" attribute ({ name: cleverage.soap.client, code: %s }) or use the #[AsSoapClient] attribute instead, so that it is only instantiated when used. It will be required in 4.0.', + $code, + $client::class, + __METHOD__, + $code, + ); + if ($this->hasClient($code)) { $message = "Client {$code} is already defined"; - if (null !== $this->serviceIds[$code] && null !== $serviceId) { - $message .= " by service \"{$this->serviceIds[$code]}\", cannot register service \"{$serviceId}\""; + $definedBy = $this->serviceIds[$code] ?? null; + if (null !== $definedBy && null !== $serviceId) { + $message .= " by service \"{$definedBy}\", cannot register service \"{$serviceId}\""; } throw new \UnexpectedValueException($message); @@ -48,10 +77,16 @@ public function addClient(ClientInterface $client, ?string $serviceId = null): v } /** - * @return ClientInterface[] + * Instantiates every client of the locator. + * + * @return array */ public function getClients(): array { + foreach (array_keys($this->locator?->getProvidedServices() ?? []) as $code) { + $this->getClient((string) $code); + } + return $this->clients; } @@ -60,15 +95,26 @@ public function getClients(): array */ public function getClient(string $code): ClientInterface { - if (!$this->hasClient($code)) { + if (isset($this->clients[$code])) { + return $this->clients[$code]; + } + if (!$this->locator?->has($code)) { throw MissingClientException::create($code); } - return $this->getClients()[$code]; + $client = $this->locator->get($code); + if (!$client instanceof ClientInterface) { + throw new \UnexpectedValueException(\sprintf('The service of the SOAP client "%s" must implement "%s".', $code, ClientInterface::class)); + } + if ($code !== $client->getCode()) { + throw new \UnexpectedValueException(\sprintf('The SOAP client "%s" is registered with the code "%s" but its getCode() method returns "%s": both must be the same.', $client::class, $code, $client->getCode())); + } + + return $this->clients[$code] = $client; } public function hasClient(string $code): bool { - return \array_key_exists($code, $this->getClients()); + return isset($this->clients[$code]) || ($this->locator?->has($code) ?? false); } } diff --git a/tests/DependencyInjection/CleverAgeSoapProcessExtensionTest.php b/tests/DependencyInjection/CleverAgeSoapProcessExtensionTest.php index 0418d55..54df54d 100644 --- a/tests/DependencyInjection/CleverAgeSoapProcessExtensionTest.php +++ b/tests/DependencyInjection/CleverAgeSoapProcessExtensionTest.php @@ -13,18 +13,25 @@ namespace CleverAge\SoapProcessBundle\Tests\DependencyInjection; +use CleverAge\SoapProcessBundle\Attribute\AsSoapClient; use CleverAge\SoapProcessBundle\DependencyInjection\CleverAgeSoapProcessExtension; use CleverAge\SoapProcessBundle\Registry\ClientRegistry; use CleverAge\SoapProcessBundle\Task\RequestTask; +use CleverAge\SoapProcessBundle\Tests\Fixtures\AttributedClient; use CleverAge\SoapProcessBundle\Transformer\RequestTransformer; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\Compiler\AttributeAutoconfigurationPass; +use Symfony\Component\DependencyInjection\Compiler\ResolveChildDefinitionsPass; +use Symfony\Component\DependencyInjection\Compiler\ResolveInstanceofConditionalsPass; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; #[CoversClass(CleverAgeSoapProcessExtension::class)] #[UsesClass(RequestTransformer::class)] +#[UsesClass(AsSoapClient::class)] class CleverAgeSoapProcessExtensionTest extends TestCase { public function testServices(): void @@ -33,6 +40,21 @@ public function testServices(): void (new CleverAgeSoapProcessExtension())->load([], $container); self::assertSame(ClientRegistry::class, $container->getDefinition('cleverage_soap_process.registry.client')->getClass()); + // Autowirable by its class name + self::assertSame('cleverage_soap_process.registry.client', (string) $container->getAlias(ClientRegistry::class)); + } + + public function testAsSoapClientAttributeRegistersTheCode(): void + { + $container = new ContainerBuilder(); + (new CleverAgeSoapProcessExtension())->load([], $container); + $container->setDefinition('app.attributed', (new Definition(AttributedClient::class))->setAutoconfigured(true)); + + (new AttributeAutoconfigurationPass())->process($container); + (new ResolveInstanceofConditionalsPass())->process($container); + (new ResolveChildDefinitionsPass())->process($container); + + self::assertSame([['code' => 'app_attributed']], $container->getDefinition('app.attributed')->getTag('cleverage.soap.client')); } public function testTaskIsRegistered(): void diff --git a/tests/DependencyInjection/Compiler/RegisterClientsPassTest.php b/tests/DependencyInjection/Compiler/RegisterClientsPassTest.php index ddf1996..242879e 100644 --- a/tests/DependencyInjection/Compiler/RegisterClientsPassTest.php +++ b/tests/DependencyInjection/Compiler/RegisterClientsPassTest.php @@ -22,31 +22,69 @@ use Psr\Log\NullLogger; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; #[CoversClass(RegisterClientsPass::class)] #[UsesClass(ClientRegistry::class)] #[UsesClass(Client::class)] class RegisterClientsPassTest extends TestCase { - public function testClientsAreRegistered(): void + public function testClientsWithACodeAreRegisteredInALocator(): void { - $container = $this->createContainer(['app.client' => 'client', 'app.other' => 'other']); + $container = $this->createContainer(['app.client' => 'client', 'app.other' => 'other'], withCode: true); $container->compile(true); /** @var ClientRegistry $registry */ $registry = $container->get('cleverage_soap_process.registry.client'); + // Not instantiated with the registry + self::assertFalse($container->initialized('app.client')); self::assertSame('client', $registry->getClient('client')->getCode()); + self::assertTrue($container->initialized('app.client')); + self::assertFalse($container->initialized('app.other')); self::assertSame('other', $registry->getClient('other')->getCode()); } - public function testDuplicateCodeGivesTheServiceIds(): void + public function testClientsWithoutCodeAreAddedToTheRegistry(): void { - $container = $this->createContainer(['app.client' => 'client', 'app.client_duplicate' => 'client']); + $container = $this->createContainer(['app.client' => 'client', 'app.other' => 'other']); $container->compile(true); - $this->expectException(\UnexpectedValueException::class); - $this->expectExceptionMessage('Client client is already defined by service "app.client", cannot register service "app.client_duplicate"'); - $container->get('cleverage_soap_process.registry.client'); + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + + return true; + }, \E_USER_DEPRECATED); + try { + /** @var ClientRegistry $registry */ + $registry = $container->get('cleverage_soap_process.registry.client'); + } finally { + restore_error_handler(); + } + + self::assertCount(2, $deprecations); + self::assertSame('client', $registry->getClient('client')->getCode()); + self::assertSame('other', $registry->getClient('other')->getCode()); + } + + public function testDuplicateCodeFailsTheCompilation(): void + { + $container = $this->createContainer(['app.client' => 'client', 'app.client_duplicate' => 'client'], withCode: true); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The SOAP client code "client" is used by the "app.client" and "app.client_duplicate" services.'); + $container->compile(true); + } + + public function testEmptyCodeFailsTheCompilation(): void + { + $container = $this->createContainer([]); + $container->setDefinition('app.client', new Definition(Client::class)) + ->addTag('cleverage.soap.client', ['code' => '']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "code" attribute of the "cleverage.soap.client" tag of the "app.client" service must be a non-empty string.'); + $container->compile(true); } public function testWithoutRegistry(): void @@ -60,15 +98,16 @@ public function testWithoutRegistry(): void /** * @param array $clients Codes of the clients, indexed by service id */ - private function createContainer(array $clients): ContainerBuilder + private function createContainer(array $clients, bool $withCode = false): ContainerBuilder { $container = new ContainerBuilder(); $container->addCompilerPass(new RegisterClientsPass()); - $container->setDefinition('cleverage_soap_process.registry.client', new Definition(ClientRegistry::class)) + $container->setDefinition('cleverage_soap_process.registry.client', new Definition(ClientRegistry::class, [null])) ->setPublic(true); foreach ($clients as $id => $code) { $container->setDefinition($id, new Definition(Client::class, [new Definition(NullLogger::class), $code, null])) - ->addTag('cleverage.soap.client'); + ->setPublic(true) + ->addTag('cleverage.soap.client', $withCode ? ['code' => $code] : []); } return $container; diff --git a/tests/Fixtures/AttributedClient.php b/tests/Fixtures/AttributedClient.php new file mode 100644 index 0000000..0c68f4a --- /dev/null +++ b/tests/Fixtures/AttributedClient.php @@ -0,0 +1,22 @@ + Codes of the clients instantiated by the locator */ + private array $instantiated = []; + + public function testClientsAreFetchedLazilyFromTheLocator(): void { - $registry = new ClientRegistry(); - $client = $this->createClient('client'); - $other = $this->createClient('other'); - $registry->addClient($client, 'app.client'); - $registry->addClient($other); + $registry = new ClientRegistry($this->createLocator(['client' => 'client', 'other' => 'other'])); - self::assertSame($client, $registry->getClient('client')); - self::assertSame($other, $registry->getClient('other')); self::assertTrue($registry->hasClient('client')); - self::assertSame(['client' => $client, 'other' => $other], $registry->getClients()); + self::assertFalse($registry->hasClient('missing')); + self::assertSame([], $this->instantiated); + + $client = $registry->getClient('client'); + self::assertSame('client', $client->getCode()); + self::assertSame($client, $registry->getClient('client')); + self::assertSame(['client'], $this->instantiated); + + self::assertSame(['client', 'other'], array_keys($registry->getClients())); + self::assertSame(['client', 'other'], $this->instantiated); } public function testMissingClient(): void { $this->expectException(MissingClientException::class); $this->expectExceptionMessage('No Soap client with code : missing'); + (new ClientRegistry($this->createLocator([])))->getClient('missing'); + } + + public function testMissingClientWithoutLocator(): void + { + $this->expectException(MissingClientException::class); (new ClientRegistry())->getClient('missing'); } + public function testCodeOfTheTagMustBeTheCodeOfTheClient(): void + { + $registry = new ClientRegistry($this->createLocator(['client' => 'other'])); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessageMatches('/is registered with the code "client" but its getCode\(\) method returns "other"/'); + $registry->getClient('client'); + } + + public function testServiceMustBeAClient(): void + { + $registry = new ClientRegistry(new ServiceLocator(['client' => static fn (): \stdClass => new \stdClass()])); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('The service of the SOAP client "client" must implement "'.ClientInterface::class.'".'); + $registry->getClient('client'); + } + + public function testAddClientIsDeprecated(): void + { + $registry = new ClientRegistry($this->createLocator(['client' => 'client'])); + $other = $this->createClient('other'); + + $deprecations = $this->collectDeprecations(static fn () => $registry->addClient($other, 'app.other')); + + self::assertSame( + ['Since cleverage/soap-process-bundle 3.3: Registering the SOAP client "other" ('.$other::class.') with "'.ClientRegistry::class.'::addClient()" is deprecated, tag its service with a "code" attribute ({ name: cleverage.soap.client, code: other }) or use the #[AsSoapClient] attribute instead, so that it is only instantiated when used. It will be required in 4.0.'], + $deprecations + ); + self::assertSame($other, $registry->getClient('other')); + self::assertEqualsCanonicalizing(['client', 'other'], array_keys($registry->getClients())); + } + + public function testAddClientWithTheCodeOfALocatorClient(): void + { + $registry = new ClientRegistry($this->createLocator(['client' => 'client'])); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessageMatches('/^Client client is already defined$/'); + $this->collectDeprecations(fn () => $registry->addClient($this->createClient('client'), 'app.client_duplicate')); + } + public function testDuplicateCodeGivesTheServiceIds(): void { $registry = new ClientRegistry(); - $registry->addClient($this->createClient('client'), 'app.client'); + $this->collectDeprecations(fn () => $registry->addClient($this->createClient('client'), 'app.client')); $this->expectException(\UnexpectedValueException::class); $this->expectExceptionMessage('Client client is already defined by service "app.client", cannot register service "app.client_duplicate"'); - $registry->addClient($this->createClient('client'), 'app.client_duplicate'); + $this->collectDeprecations(fn () => $registry->addClient($this->createClient('client'), 'app.client_duplicate')); } public function testDuplicateCodeWithoutServiceIds(): void { $registry = new ClientRegistry(); - $registry->addClient($this->createClient('client')); + $this->collectDeprecations(fn () => $registry->addClient($this->createClient('client'))); $this->expectException(\UnexpectedValueException::class); $this->expectExceptionMessageMatches('/^Client client is already defined$/'); - $registry->addClient($this->createClient('client'), 'app.client_duplicate'); + $this->collectDeprecations(fn () => $registry->addClient($this->createClient('client'), 'app.client_duplicate')); + } + + /** + * @param array $codes Code returned by each client, indexed by the code of the locator + * + * @return ServiceLocator + */ + private function createLocator(array $codes): ServiceLocator + { + $factories = []; + foreach ($codes as $code => $clientCode) { + $factories[$code] = function () use ($code, $clientCode): ClientInterface { + $this->instantiated[] = $code; + + return $this->createClient($clientCode); + }; + } + + return new ServiceLocator($factories); } private function createClient(string $code): ClientInterface @@ -72,4 +146,24 @@ private function createClient(string $code): ClientInterface return $client; } + + /** + * @return list + */ + private function collectDeprecations(callable $callable): array + { + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + + return true; + }, \E_USER_DEPRECATED); + try { + $callable(); + } finally { + restore_error_handler(); + } + + return $deprecations; + } } diff --git a/tests/Task/RequestTaskTest.php b/tests/Task/RequestTaskTest.php index 751f56f..c64c13a 100644 --- a/tests/Task/RequestTaskTest.php +++ b/tests/Task/RequestTaskTest.php @@ -28,6 +28,7 @@ use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Psr\Log\AbstractLogger; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; #[CoversClass(RequestTask::class)] @@ -150,8 +151,8 @@ public function testInvalidHeader(): void */ private function createTask(array $options): array { - $registry = new ClientRegistry(); - $registry->addClient($this->client); + $client = $this->client; + $registry = new ClientRegistry(new ServiceLocator(['test' => static fn (): Client => $client])); $processConfiguration = new ProcessConfiguration('test', []); $state = new ProcessState($processConfiguration, new ProcessHistory($processConfiguration)); diff --git a/tests/Transformer/RequestTransformerTest.php b/tests/Transformer/RequestTransformerTest.php index b185714..7a6f4d2 100644 --- a/tests/Transformer/RequestTransformerTest.php +++ b/tests/Transformer/RequestTransformerTest.php @@ -23,6 +23,7 @@ use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; +use Symfony\Component\DependencyInjection\ServiceLocator; #[CoversClass(RequestTransformer::class)] #[CoversTrait(SoapCallOptionsTrait::class)] @@ -41,8 +42,8 @@ protected function setUp(): void $this->soapClient = new FakeSoapClient(['GetBook' => 'xsi:type="xsd:string">It', 'IsAvailable' => 'xsi:type="xsd:boolean">false']); $this->client = new Client(new NullLogger(), 'test', null); $this->client->setSoapClient($this->soapClient); - $registry = new ClientRegistry(); - $registry->addClient($this->client); + $client = $this->client; + $registry = new ClientRegistry(new ServiceLocator(['test' => static fn (): Client => $client])); $this->transformer = new RequestTransformer($registry); } From 97a6f8eb7e67dedaa0463dae7fb1d28c3cb3a485 Mon Sep 17 00:00:00 2001 From: Nicolas Joubert Date: Thu, 15 Oct 2026 18:02:45 +0200 Subject: [PATCH 2/2] #52 - Tests: cover the AsSoapClient attribute, declare the registry used by the extension tests PHPUnit with coverage (CI) reported the extension test instantiating the registry as risky (code not listed in UsesClass), and the attribute constructor as not covered. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../DependencyInjection/CleverAgeSoapProcessExtensionTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/DependencyInjection/CleverAgeSoapProcessExtensionTest.php b/tests/DependencyInjection/CleverAgeSoapProcessExtensionTest.php index 54df54d..d11506f 100644 --- a/tests/DependencyInjection/CleverAgeSoapProcessExtensionTest.php +++ b/tests/DependencyInjection/CleverAgeSoapProcessExtensionTest.php @@ -31,7 +31,8 @@ #[CoversClass(CleverAgeSoapProcessExtension::class)] #[UsesClass(RequestTransformer::class)] -#[UsesClass(AsSoapClient::class)] +#[CoversClass(AsSoapClient::class)] +#[UsesClass(ClientRegistry::class)] class CleverAgeSoapProcessExtensionTest extends TestCase { public function testServices(): void